docs/superpowers/plans/2026-05-31-modern-ui.md
Rama master · réplica de lectura del repositorio original.
# Broadlink Manager Modern UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the AdminLTE/jQuery/Jinja2 frontend with a React 18 + TypeScript SPA and refactor the monolithic `broadlinkmanager.py` into clean FastAPI router modules.
**Architecture:** FastAPI backend split into `app/routers/` (devices, commands, codes); React SPA in `web/` built by Vite into `dist/`; FastAPI serves `dist/` as static files with a SPA catch-all fallback. All existing API contracts preserved exactly.
**Tech Stack:** Python 3.12 / FastAPI / Pydantic v2, React 18 / TypeScript / Vite / Tailwind CSS v3 / TanStack Query v5 / React Router v6 / Lucide React
---
## Phase 1 — Backend Refactor
### Task 1: Create app/ package structure, config, state, and models
**Files:**
- Create: `broadlinkmanager/app/__init__.py`
- Create: `broadlinkmanager/app/config.py`
- Create: `broadlinkmanager/app/state.py`
- Create: `broadlinkmanager/app/models.py`
- Create: `broadlinkmanager/app/routers/__init__.py`
- [ ] **Step 1: Create the app package and routers sub-package**
```bash
mkdir -p broadlinkmanager/app/routers
touch broadlinkmanager/app/__init__.py
touch broadlinkmanager/app/routers/__init__.py
```
- [ ] **Step 2: Create `broadlinkmanager/app/config.py`**
```python
import os
import re
import subprocess
import argparse
import sys
from loguru import logger
ip_format_regex = r"\b(((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]))\b"
def validate_ip(ip: str) -> bool:
return bool(re.search(ip_format_regex, ip))
def parse_ip_list(iplist: str) -> list[str]:
parsed = re.findall(ip_format_regex, iplist)
return [item[0] for item in parsed]
def get_local_ip_list() -> list[str]:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True)
ips = parse_ip_list(result.stdout)
logger.debug(f"Local IP list: {ips}")
return ips
def get_env_ip_list() -> list[str]:
env = os.getenv("DISCOVERY_IP_LIST", "")
result = parse_ip_list(str(env))
logger.debug(f"Env IP list: {result}")
return result
def get_version() -> str:
try:
with open(os.path.join(os.path.dirname(__file__), "..", "VERSION"), "r") as f:
return f.read().strip()
except Exception:
return "unknown"
def get_devices_file_path() -> str:
return os.path.join(os.path.dirname(__file__), "..", "data", "devices.json")
parser = argparse.ArgumentParser(fromfile_prefix_chars="@")
parser.add_argument("--timeout", type=int, default=5)
parser.add_argument("--ip", action="append", default=[])
parser.add_argument("--dst-ip", default="255.255.255.255")
args = parser.parse_args()
if args.ip:
invalid = [ip for ip in args.ip if not validate_ip(ip)]
if invalid:
logger.error(f"Invalid IPs: {invalid}")
sys.exit(-1)
discovery_ip_address_list: list[str] = args.ip
else:
env_list = get_env_ip_list()
discovery_ip_address_list = env_list if env_list else get_local_ip_list()
logger.info(f"Discovery interfaces: {discovery_ip_address_list}")
```
- [ ] **Step 3: Create `broadlinkmanager/app/state.py`**
```python
# Mutable RF sweep state — shared between /rf/learn, /rf/status, /rf/continue
_continu_to_sweep: bool = False
_rf_sweep_message: str = ""
_rf_sweep_status: bool = False
def reset_rf_state() -> None:
global _continu_to_sweep, _rf_sweep_message, _rf_sweep_status
_continu_to_sweep = False
_rf_sweep_message = ""
_rf_sweep_status = False
def set_rf_message(msg: str) -> None:
global _rf_sweep_message
_rf_sweep_message = msg
def set_rf_status(status: bool) -> None:
global _rf_sweep_status
_rf_sweep_status = status
def set_continue_sweep(value: bool) -> None:
global _continu_to_sweep
_continu_to_sweep = value
def get_rf_state() -> dict:
return {
"_continu_to_sweep": str(_continu_to_sweep),
"_rf_sweep_message": _rf_sweep_message,
"_rf_sweep_status": str(_rf_sweep_status),
}
```
- [ ] **Step 4: Create `broadlinkmanager/app/models.py`**
```python
from pydantic import BaseModel
from typing import Optional
class DeviceInfo(BaseModel):
name: str
type: str
ip: str
mac: str
class PingResult(BaseModel):
status: str
success: bool
class CodeBase(BaseModel):
CodeType: str
CodeName: str
Code: str
class CodeRecord(CodeBase):
CodeId: int
class OperationResult(BaseModel):
success: int
message: str
class VersionInfo(BaseModel):
version: str
```
- [ ] **Step 5: Verify files exist**
```bash
ls broadlinkmanager/app/ broadlinkmanager/app/routers/
```
Expected: `__init__.py config.py models.py routers/ state.py` and `__init__.py` in routers.
- [ ] **Step 6: Commit**
```bash
git add broadlinkmanager/app/
git commit -m "Add app/ package: config, state, models, router stubs"
```
---
### Task 2: Refactor db.py with context manager
**Files:**
- Create: `broadlinkmanager/app/db.py`
- [ ] **Step 1: Create `broadlinkmanager/app/db.py`**
```python
import os
import sqlite3
from contextlib import contextmanager
from loguru import logger
DB_PATH = os.getenv("DB_PATH", os.path.join(os.path.dirname(__file__), "..", "data", "codes.db"))
@contextmanager
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def create_tables() -> None:
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS Codes (
CodeId INTEGER PRIMARY KEY AUTOINCREMENT,
CodeType TEXT NOT NULL,
CodeName TEXT NOT NULL,
Code TEXT NOT NULL
)
""")
logger.info("Database tables ready")
def insert_code(code_type: str, code_name: str, code: str) -> dict:
try:
with get_connection() as conn:
conn.execute(
"INSERT INTO Codes (CodeType, CodeName, Code) VALUES (?, ?, ?)",
(code_type, code_name, code),
)
return {"message": "Code inserted successfully.", "success": 1}
except Exception as e:
logger.error(f"insert_code failed: {e}")
return {"message": str(e), "success": 0}
def update_code(code_id: int, code_type: str = None, code_name: str = None, code: str = None) -> dict:
try:
parts, params = [], []
if code_type:
parts.append("CodeType=?"); params.append(code_type)
if code_name:
parts.append("CodeName=?"); params.append(code_name)
if code:
parts.append("Code=?"); params.append(code)
if not parts:
return {"message": "Nothing to update.", "success": 0}
params.append(code_id)
with get_connection() as conn:
conn.execute(f"UPDATE Codes SET {', '.join(parts)} WHERE CodeId=?", params)
return {"message": "Code updated successfully.", "success": 1}
except Exception as e:
logger.error(f"update_code failed: {e}")
return {"message": str(e), "success": 0}
def delete_code(code_id: int) -> dict:
try:
with get_connection() as conn:
conn.execute("DELETE FROM Codes WHERE CodeId=?", (code_id,))
return {"message": "Code deleted successfully.", "success": 1}
except Exception as e:
logger.error(f"delete_code failed: {e}")
return {"message": str(e), "success": 0}
def select_code(code_id: int) -> list[dict]:
try:
with get_connection() as conn:
rows = conn.execute(
"SELECT CodeId, CodeType, CodeName, Code FROM Codes WHERE CodeId=?", (code_id,)
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"select_code failed: {e}")
return []
def select_all_codes() -> list[dict]:
try:
with get_connection() as conn:
rows = conn.execute(
"SELECT CodeId, CodeType, CodeName, Code FROM Codes"
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"select_all_codes failed: {e}")
return []
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/app/db.py
git commit -m "Add app/db.py: SqliteConnector refactored to context-manager pattern"
```
---
### Task 3: Create codes router
**Files:**
- Create: `broadlinkmanager/app/routers/codes.py`
- [ ] **Step 1: Create `broadlinkmanager/app/routers/codes.py`**
```python
from fastapi import APIRouter
from app.models import CodeBase, CodeRecord, OperationResult
from app import db
router = APIRouter(prefix="/api", tags=["Codes"])
@router.get("/codes", response_model=list[dict])
def list_codes():
return db.select_all_codes()
@router.get("/code/{code_id}", response_model=list[dict])
def get_code(code_id: int):
return db.select_code(code_id)
@router.post("/code")
def create_code(code: CodeBase):
return db.insert_code(code.CodeType, code.CodeName, code.Code)
@router.put("/code/{code_id}")
def update_code(code_id: int, code: CodeBase):
return db.update_code(code_id, code.CodeType, code.CodeName, code.Code)
@router.delete("/code/{code_id}")
def delete_code(code_id: int):
return db.delete_code(code_id)
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/app/routers/codes.py
git commit -m "Add app/routers/codes.py: code CRUD endpoints"
```
---
### Task 4: Create devices router
**Files:**
- Create: `broadlinkmanager/app/routers/devices.py`
- [ ] **Step 1: Create `broadlinkmanager/app/routers/devices.py`**
```python
import json
import subprocess
import time
from os import path
import broadlink
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from loguru import logger
from app.config import args, discovery_ip_address_list, get_devices_file_path
router = APIRouter(tags=["Devices"])
_DEVICE_NAMES: dict[int, str] = {
0x0000: "SP1 (Broadlink)", 0x2717: "NEO (Ankuoo)", 0x2719: "SP2-compatible (Honeywell)",
0x271A: "SP2-compatible (Honeywell)", 0x2720: "SPmini (Broadlink)", 0x2728: "SP2-compatible (URANT)",
0x273E: "SPmini (Broadlink)", 0x7530: "SP2 (Broadlink OEM)", 0x7539: "SP2-IL (Broadlink OEM)",
0x753E: "SPmini3 (Broadlink)", 0x7540: "MP2 (Broadlink)", 0x7544: "SP2-CL (Broadlink)",
0x7546: "SP2-UK/BR/IN (Broadlink OEM)", 0x7547: "SC1 (Broadlink)", 0x7918: "SP2 (Broadlink OEM)",
0x7919: "SP2-compatible (Honeywell)", 0x791A: "SP2-compatible (Honeywell)",
0x7D0D: "SPmini3 (Broadlink OEM)", 0x2711: "SP2 (Broadlink)", 0x2716: "NEOPRO (Ankuoo)",
0x271D: "Ego (Efergy)", 0x2736: "SPmini+ (Broadlink)", 0x2733: "SP3 (Broadlink)",
0x7D00: "SP3-EU (Broadlink OEM)", 0x9479: "SP3S-US (Broadlink)", 0x947A: "SP3S-EU (Broadlink)",
0x7568: "SP4L-CN (Broadlink)", 0x756C: "SP4M (Broadlink)", 0x756F: "MCB1 (Broadlink)",
0x7579: "SP4L-EU (Broadlink)", 0x757B: "SP4L-AU (Broadlink)", 0x7583: "SPmini3 (Broadlink)",
0x7587: "SP4L-UK (Broadlink)", 0x7D11: "SPmini3 (Broadlink)", 0xA56A: "MCB1 (Broadlink)",
0xA56B: "SCB1E (Broadlink)", 0xA56C: "SP4L-EU (Broadlink)", 0xA589: "SP4L-UK (Broadlink)",
0xA5D3: "SP4L-EU (Broadlink)", 0x5115: "SCB1E (Broadlink)", 0x51E2: "AHC/U-01 (BGElectrical)",
0x6111: "MCB1 (Broadlink)", 0x6113: "SCB1E (Broadlink)", 0x618B: "SP4L-EU (Broadlink)",
0x6489: "SP4L-AU (Broadlink)", 0x648B: "SP4M-US (Broadlink)", 0x6494: "SCB2 (Broadlink)",
0x2737: "RMmini3 (Broadlink)", 0x278F: "RMmini (Broadlink)", 0x27C2: "RMmini3 (Broadlink)",
0x27C7: "RMmini3 (Broadlink)", 0x27CC: "RMmini3 (Broadlink)", 0x27CD: "RMmini3 (Broadlink)",
0x27D0: "RMmini3 (Broadlink)", 0x27D1: "RMmini3 (Broadlink)", 0x27D3: "RMmini3 (Broadlink)",
0x27DC: "RMmini3 (Broadlink)", 0x27DE: "RMmini3 (Broadlink)", 0x2712: "RMpro/pro+ (Broadlink)",
0x272A: "RMpro (Broadlink)", 0x273D: "RMpro (Broadlink)", 0x277C: "RMhome (Broadlink)",
0x2783: "RMhome (Broadlink)", 0x2787: "RMpro (Broadlink)", 0x278B: "RMplus (Broadlink)",
0x2797: "RMpro+ (Broadlink)", 0x279D: "RMpro+ (Broadlink)", 0x27A1: "RMplus (Broadlink)",
0x27A6: "RMplus (Broadlink)", 0x27A9: "RMpro+ (Broadlink)", 0x27C3: "RMpro+ (Broadlink)",
0x5F36: "RMmini3 (Broadlink)", 0x6507: "RMmini3 (Broadlink)", 0x6508: "RMmini3 (Broadlink)",
0x51DA: "RM4mini (Broadlink)", 0x520D: "RM4C mini (Broadlink)", 0x6070: "RM4Cmini (Broadlink)",
0x610E: "RM4mini (Broadlink)", 0x610F: "RM4Cmini (Broadlink)", 0x62BC: "RM4mini (Broadlink)",
0x62BE: "RM4Cmini (Broadlink)", 0x6364: "RM4S (Broadlink)", 0x648D: "RM4mini (Broadlink)",
0x6539: "RM4Cmini (Broadlink)", 0x653A: "RM4mini (Broadlink)", 0x6026: "RM4pro (Broadlink)",
0x6184: "RM4Cpro (Broadlink)", 0x61A2: "RM4pro (Broadlink)", 0x649B: "RM4pro (Broadlink)",
0x653C: "RM4pro (Broadlink)", 0x2714: "e-Sensor (Broadlink)", 0x5043: "SB800TD (Broadlink OEM)",
0x504E: "LB1 (Broadlink)", 0x606E: "SB500TD (Broadlink OEM)", 0x60C7: "LB1 (Broadlink)",
0x60C8: "LB1 (Broadlink)", 0x6112: "LB1 (Broadlink)", 0xA4F4: "LB27R1 (Broadlink)",
0x2722: "S2KIT (Broadlink)", 0xA59C: "S3 (Broadlink)", 0xA64D: "S3 (Broadlink)",
0x4EAD: "HY02/HY03 (Hysen)", 0x4E4D: "DT360E-45/20 (Dooya)", 0x51E3: "BG800/BG900 (BGElectrical)",
0xA569: "SP4L-UK (Broadlink)", 0x520C: "RM4 mini (Broadlink)", 0x5212: "RM4 TV mate (Broadlink)",
0x5211: "RM4C mate (Broadlink)", 0x521C: "RM4 mini (Broadlink)", 0x5216: "RM4 mini (Broadlink)",
0x520B: "RM4 pro (Broadlink)", 0x5218: "RM4C pro (Broadlink)", 0x5209: "RM4 TV mate (Broadlink)",
0x5213: "RM4 pro (Broadlink)", 0x644B: "LB1 (Broadlink)", 0x644C: "LB27 R1 (Broadlink)",
0x644E: "LB26 R1 (Broadlink)", 0xA5F7: "LB27 R1 (Broadlink)", 0x648C: "SP4L-US (Broadlink)",
}
def _get_device_name(devtype: int) -> str:
return _DEVICE_NAMES.get(devtype, "Not Supported")
def _process_device(device) -> dict | None:
try:
if not device.auth():
logger.warning(f"Auth failed for {device.host[0]}")
return None
mac = "".join(format(x, "02x") for x in device.mac)
name = _get_device_name(device.devtype)
logger.info(f"Found: {name} at {device.host[0]} ({mac})")
return {"name": name, "type": format(hex(device.devtype)), "ip": device.host[0], "mac": mac}
except Exception as e:
logger.error(f"Error processing device: {e}")
return None
@router.get("/autodiscover", tags=["Devices"])
def autodiscover(freshscan: str = "1"):
devices_path = get_devices_file_path()
if path.exists(devices_path) and freshscan != "1":
try:
with open(devices_path, "r") as f:
return JSONResponse(json.loads(f.read().replace("'", '"')))
except Exception as e:
logger.error(f"Failed to load devices file: {e}")
logger.info("Scanning for devices...")
found: list[dict] = []
for iface in discovery_ip_address_list:
try:
devices = broadlink.discover(timeout=5, local_ip_address=iface, discover_ip_address=args.dst_ip)
for d in devices:
info = _process_device(d)
if info:
found.append(info)
except OSError as e:
logger.error(f"Discovery failed on {iface}: {e}")
logger.info(f"Found {len(found)} device(s)")
return JSONResponse(found)
@router.get("/device/ping", tags=["Devices"])
def ping_device(host: str = ""):
if not host:
return JSONResponse({"status": "Host parameter is required", "success": False})
try:
result = subprocess.run(["ping", "-c", "1", "-W", "3", host], capture_output=True, timeout=5)
status = "online" if result.returncode == 0 else "offline"
return JSONResponse({"status": status, "success": True})
except subprocess.TimeoutExpired:
return JSONResponse({"status": "timeout", "success": True})
except Exception as e:
logger.error(f"Ping error for {host}: {e}")
return JSONResponse({"status": "error", "success": False})
@router.post("/devices/save", include_in_schema=False)
async def save_devices(request: Request):
data = await request.json()
try:
with open(get_devices_file_path(), "w") as f:
f.write(str(data).replace("'", '"'))
return JSONResponse({"success": 1})
except Exception as e:
logger.error(f"Save devices failed: {e}")
return JSONResponse({"success": 0})
@router.get("/devices/load", include_in_schema=False)
def load_devices():
try:
with open(get_devices_file_path(), "r") as f:
return JSONResponse(json.loads(f.read().replace("'", '"')))
except Exception as e:
logger.error(f"Load devices failed: {e}")
return JSONResponse({"success": 0})
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/app/routers/devices.py
git commit -m "Add app/routers/devices.py: autodiscover, ping, save/load endpoints"
```
---
### Task 5: Create commands router
**Files:**
- Create: `broadlinkmanager/app/routers/commands.py`
- [ ] **Step 1: Create `broadlinkmanager/app/routers/commands.py`**
```python
import time
import broadlink
from broadlink.exceptions import ReadError, StorageError
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from loguru import logger
from app import state
router = APIRouter(tags=["Commands"])
TICK = 32.84
IR_TOKEN = 0x26
TIMEOUT = 30
def _init_device(dtype: str, host: str, mac: str):
dtype_tmp = dtype
if dtype_tmp == "0x6539":
dtype_tmp = "0x610F"
return broadlink.gendevice(int(dtype_tmp, 0), (host, 80), bytearray.fromhex(mac))
@router.get("/temperature", tags=["Commands"], summary="Read Temperature")
def temperature(mac: str = "", host: str = "", type: str = ""):
logger.info(f"Reading temperature from {host}")
dev = _init_device(type, host, mac)
dev.auth()
try:
return JSONResponse({"data": str(dev.check_temperature()), "success": "1"})
except Exception:
return JSONResponse({"data": "Method Not Supported", "success": "0"})
@router.get("/ir/learn", tags=["Commands"], summary="Learn IR code")
def learn_ir(mac: str = "", host: str = "", type: str = "", command: str = ""):
logger.info(f"Learning IR on {host}")
dev = _init_device(type, host, mac)
dev.auth()
dev.enter_learning()
start = time.time()
while time.time() - start < TIMEOUT:
time.sleep(1)
try:
data = dev.check_data()
except (ReadError, StorageError):
continue
else:
break
else:
return JSONResponse({"data": "", "success": 0, "message": "No Data Received", "type": "ir"})
learned = "".join(format(x, "02x") for x in bytearray(data))
return JSONResponse({"data": learned, "success": 1, "message": "IR Data Received", "type": "ir"})
@router.get("/command/send", tags=["Commands"], summary="Send IR/RF Command")
def send_command(mac: str = "", host: str = "", type: str = "", command: str = ""):
logger.info(f"Sending command to {host}")
dev = _init_device(type, host, mac)
dev.auth()
try:
dev.send_data(bytearray.fromhex("".join(command)))
return JSONResponse({"data": "", "success": 1, "message": "Command sent successfully"})
except Exception as ex:
return JSONResponse({"data": "", "success": 0, "message": f"Error: {ex}"})
@router.get("/rf/learn", include_in_schema=False)
def learn_rf(mac: str = "", host: str = "", type: str = "", command: str = ""):
state.reset_rf_state()
logger.info(f"Starting RF sweep on {host}")
dev = _init_device(type, host, mac)
dev.auth()
dev.sweep_frequency()
state.set_rf_message("Learning RF Frequency, press and hold the button...")
start = time.time()
while time.time() - start < TIMEOUT:
time.sleep(1)
if dev.check_frequency():
break
else:
state.set_rf_message("RF Frequency not found!")
dev.cancel_sweep_frequency()
return JSONResponse({"data": "RF Frequency not found!", "success": 0, "type": "rf"})
state.set_rf_message("Found RF Frequency - 1 of 2!")
time.sleep(1)
state.set_rf_message("You can now let go of the button")
state.set_rf_status(True)
while not state._continu_to_sweep:
state.set_rf_message("Click the Continue button")
time.sleep(0.1)
state.set_rf_message("Single press the button to capture the code")
state.set_rf_status(False)
dev.find_rf_packet()
start = time.time()
while time.time() - start < TIMEOUT:
time.sleep(1)
try:
data = dev.check_data()
except (ReadError, StorageError):
continue
else:
break
else:
state.set_rf_message("No Data Found")
return JSONResponse({"data": "No Data Found", "success": 0, "type": "rf"})
learned = "".join(format(x, "02x") for x in bytearray(data))
state.set_rf_message("RF Scan Completed Successfully")
return JSONResponse({"data": learned, "success": 1, "type": "rf"})
@router.get("/rf/status", include_in_schema=False)
def rf_status():
return JSONResponse(state.get_rf_state())
@router.get("/rf/continue", include_in_schema=False)
def rf_continue():
state.set_rf_status(True)
state.set_continue_sweep(True)
return JSONResponse(state.get_rf_state())
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/app/routers/commands.py
git commit -m "Add app/routers/commands.py: IR/RF learn, send, temperature endpoints"
```
---
### Task 6: Create app/main.py and update the launcher
**Files:**
- Create: `broadlinkmanager/app/main.py`
- Modify: `broadlinkmanager/broadlinkmanager.py`
- [ ] **Step 1: Create `broadlinkmanager/app/main.py`**
```python
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette_exporter import PrometheusMiddleware, handle_metrics
from app import db
from app.config import get_version
from app.routers import codes, commands, devices
DIST_DIR = os.path.join(os.path.dirname(__file__), "..", "dist")
def create_app() -> FastAPI:
db.create_tables()
app = FastAPI(
title="Broadlink Manager",
description="Manage Broadlink IR/RF devices",
version=get_version(),
contact={"name": "Tomer Klein", "email": "tomer.klein@gmail.com",
"url": "https://github.com/t0mer/broadlinkmanager-docker"},
)
app.add_middleware(PrometheusMiddleware)
app.add_route("/metrics", handle_metrics)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(devices.router)
app.include_router(commands.router)
app.include_router(codes.router)
@app.get("/api/version")
def version():
return JSONResponse({"version": get_version()})
# Serve React SPA static files if dist/ exists
if os.path.isdir(DIST_DIR):
app.mount("/assets", StaticFiles(directory=os.path.join(DIST_DIR, "assets")), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str):
index = os.path.join(DIST_DIR, "index.html")
if os.path.isfile(index):
return FileResponse(index)
return JSONResponse({"detail": "Frontend not built yet"}, status_code=404)
return app
app = create_app()
```
- [ ] **Step 2: Replace `broadlinkmanager/broadlinkmanager.py` with a thin launcher**
```python
import sys
import os
# Ensure the broadlinkmanager/ directory is on the path
sys.path.insert(0, os.path.dirname(__file__))
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=7020, reload=False)
```
- [ ] **Step 3: Verify the backend starts**
Run from the `broadlinkmanager/` directory:
```bash
cd broadlinkmanager && python broadlinkmanager.py &
sleep 3
curl -s http://localhost:7020/api/version
curl -s http://localhost:7020/api/codes
curl -s "http://localhost:7020/device/ping?host=127.0.0.1"
kill %1
cd ..
```
Expected: `{"version":"6.0.0"}`, `[]`, `{"status":"online","success":true}`
- [ ] **Step 4: Commit**
```bash
git add broadlinkmanager/app/main.py broadlinkmanager/broadlinkmanager.py
git commit -m "Add app/main.py FastAPI factory; replace broadlinkmanager.py with thin launcher"
```
---
## Phase 2 — Frontend Scaffold
### Task 7: Scaffold Vite + React + TypeScript + Tailwind
**Files:**
- Create: `broadlinkmanager/web/` (entire directory)
- [ ] **Step 1: Scaffold with Vite**
```bash
cd broadlinkmanager
npm create vite@latest web -- --template react-ts
cd web
npm install
```
- [ ] **Step 2: Install dependencies**
```bash
npm install \
@tanstack/react-query \
react-router-dom \
lucide-react \
tailwindcss \
postcss \
autoprefixer
npm install -D @types/node
npx tailwindcss init -p
```
- [ ] **Step 3: Replace `web/tailwind.config.ts`**
```typescript
import type { Config } from 'tailwindcss'
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
sky: {
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
},
},
},
},
plugins: [],
} satisfies Config
```
- [ ] **Step 4: Replace `web/src/index.css`**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
* {
box-sizing: border-box;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 2px; }
```
- [ ] **Step 5: Replace `web/vite.config.ts`**
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:7020',
'/autodiscover': 'http://localhost:7020',
'/device': 'http://localhost:7020',
'/devices': 'http://localhost:7020',
'/ir': 'http://localhost:7020',
'/rf': 'http://localhost:7020',
'/command': 'http://localhost:7020',
'/temperature': 'http://localhost:7020',
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
},
})
```
- [ ] **Step 6: Delete the Vite boilerplate files that will be replaced**
```bash
rm -f web/src/App.tsx web/src/App.css web/src/assets/react.svg public/vite.svg
mkdir -p web/src/{api,components/layout,components/devices,components/commands,components/ui,pages,hooks,contexts,types}
```
- [ ] **Step 7: Verify Vite starts**
```bash
cd web && npm run dev &
sleep 4
curl -s http://localhost:5173/ | head -5
kill %1
cd ../..
```
Expected: HTML output starting with `<!doctype html>`
- [ ] **Step 8: Commit**
```bash
git add broadlinkmanager/web/
git commit -m "Scaffold web/ with Vite + React + TypeScript + Tailwind + TanStack Query"
```
---
### Task 8: TypeScript types and API layer
**Files:**
- Create: `broadlinkmanager/web/src/types/index.ts`
- Create: `broadlinkmanager/web/src/api/devices.ts`
- Create: `broadlinkmanager/web/src/api/commands.ts`
- Create: `broadlinkmanager/web/src/api/codes.ts`
- [ ] **Step 1: Create `web/src/types/index.ts`**
```typescript
export interface Device {
name: string;
type: string;
ip: string;
mac: string;
}
export interface PingResult {
status: 'online' | 'offline' | 'timeout' | 'error';
success: boolean;
}
export interface Code {
CodeId: number;
CodeType: string;
CodeName: string;
Code: string;
}
export interface CodeInput {
CodeType: string;
CodeName: string;
Code: string;
}
export interface OperationResult {
success: number;
message: string;
}
export interface RfState {
_continu_to_sweep: string;
_rf_sweep_message: string;
_rf_sweep_status: string;
}
export interface CommandResult {
data: string;
success: number;
message: string;
type?: string;
}
```
- [ ] **Step 2: Create `web/src/api/devices.ts`**
```typescript
import type { Device, PingResult } from '@/types';
export async function fetchDevices(freshscan = true): Promise<Device[]> {
const res = await fetch(`/autodiscover?freshscan=${freshscan ? '1' : '0'}`);
if (!res.ok) throw new Error('Failed to fetch devices');
const data = await res.json();
return Array.isArray(data) ? data : [];
}
export async function pingDevice(host: string): Promise<PingResult> {
const res = await fetch(`/device/ping?host=${encodeURIComponent(host)}`);
if (!res.ok) throw new Error('Ping failed');
return res.json();
}
export async function saveDevices(devices: Device[]): Promise<void> {
const res = await fetch('/devices/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(devices),
});
if (!res.ok) throw new Error('Failed to save devices');
}
export async function loadDevices(): Promise<Device[]> {
const res = await fetch('/devices/load');
if (!res.ok) throw new Error('Failed to load devices');
const data = await res.json();
return Array.isArray(data) ? data : [];
}
```
- [ ] **Step 3: Create `web/src/api/commands.ts`**
```typescript
import type { CommandResult, RfState } from '@/types';
export async function learnIr(host: string, mac: string, type: string): Promise<CommandResult> {
const params = new URLSearchParams({ host, mac, type });
const res = await fetch(`/ir/learn?${params}`);
if (!res.ok) throw new Error('IR learn request failed');
return res.json();
}
export async function sendCommand(host: string, mac: string, type: string, command: string): Promise<CommandResult> {
const params = new URLSearchParams({ host, mac, type, command });
const res = await fetch(`/command/send?${params}`);
if (!res.ok) throw new Error('Send command failed');
return res.json();
}
export async function learnRf(host: string, mac: string, type: string): Promise<CommandResult> {
const params = new URLSearchParams({ host, mac, type });
const res = await fetch(`/rf/learn?${params}`);
if (!res.ok) throw new Error('RF learn request failed');
return res.json();
}
export async function fetchRfStatus(): Promise<RfState> {
const res = await fetch('/rf/status');
if (!res.ok) throw new Error('RF status failed');
return res.json();
}
export async function continueRf(): Promise<RfState> {
const res = await fetch('/rf/continue');
if (!res.ok) throw new Error('RF continue failed');
return res.json();
}
export async function readTemperature(host: string, mac: string, type: string): Promise<{ data: string; success: string }> {
const params = new URLSearchParams({ host, mac, type });
const res = await fetch(`/temperature?${params}`);
if (!res.ok) throw new Error('Temperature read failed');
return res.json();
}
```
- [ ] **Step 4: Create `web/src/api/codes.ts`**
```typescript
import type { Code, CodeInput, OperationResult } from '@/types';
export async function fetchAllCodes(): Promise<Code[]> {
const res = await fetch('/api/codes');
if (!res.ok) throw new Error('Failed to fetch codes');
return res.json();
}
export async function fetchCode(id: number): Promise<Code[]> {
const res = await fetch(`/api/code/${id}`);
if (!res.ok) throw new Error('Failed to fetch code');
return res.json();
}
export async function createCode(input: CodeInput): Promise<OperationResult> {
const res = await fetch('/api/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Failed to create code');
return res.json();
}
export async function updateCode(id: number, input: CodeInput): Promise<OperationResult> {
const res = await fetch(`/api/code/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Failed to update code');
return res.json();
}
export async function deleteCode(id: number): Promise<OperationResult> {
const res = await fetch(`/api/code/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete code');
return res.json();
}
```
- [ ] **Step 5: Commit**
```bash
git add broadlinkmanager/web/src/types/ broadlinkmanager/web/src/api/
git commit -m "Add TypeScript types and API layer (devices, commands, codes)"
```
---
### Task 9: Theme system
**Files:**
- Create: `broadlinkmanager/web/src/contexts/ThemeContext.tsx`
- Create: `broadlinkmanager/web/src/hooks/useTheme.ts`
- [ ] **Step 1: Create `web/src/contexts/ThemeContext.tsx`**
```tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'dark' | 'light';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
toggleTheme: () => {},
});
function getInitialTheme(): Theme {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
const html = document.documentElement;
if (theme === 'dark') {
html.classList.add('dark');
html.classList.remove('light');
} else {
html.classList.remove('dark');
html.classList.add('light');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/web/src/contexts/ThemeContext.tsx
git commit -m "Add ThemeContext with dark/light toggle persisted to localStorage"
```
---
### Task 10: PanelContext for the command slide-over
**Files:**
- Create: `broadlinkmanager/web/src/contexts/PanelContext.tsx`
- [ ] **Step 1: Create `web/src/contexts/PanelContext.tsx`**
```tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
import type { Device } from '@/types';
type PanelTab = 'ir' | 'rf' | 'send';
interface PanelState {
isOpen: boolean;
device: Device | null;
activeTab: PanelTab;
prefilledCode: string;
}
interface PanelContextValue extends PanelState {
openPanel: (device: Device, tab?: PanelTab, code?: string) => void;
closePanel: () => void;
setTab: (tab: PanelTab) => void;
}
const PanelContext = createContext<PanelContextValue>({
isOpen: false,
device: null,
activeTab: 'ir',
prefilledCode: '',
openPanel: () => {},
closePanel: () => {},
setTab: () => {},
});
export function PanelProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<PanelState>({
isOpen: false,
device: null,
activeTab: 'ir',
prefilledCode: '',
});
const openPanel = (device: Device, tab: PanelTab = 'ir', code = '') => {
setState({ isOpen: true, device, activeTab: tab, prefilledCode: code });
};
const closePanel = () => setState(s => ({ ...s, isOpen: false, prefilledCode: '' }));
const setTab = (tab: PanelTab) => setState(s => ({ ...s, activeTab: tab }));
return (
<PanelContext.Provider value={{ ...state, openPanel, closePanel, setTab }}>
{children}
</PanelContext.Provider>
);
}
export function usePanel() {
return useContext(PanelContext);
}
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/web/src/contexts/PanelContext.tsx
git commit -m "Add PanelContext for command slide-over panel state"
```
---
### Task 11: UI primitives
**Files:**
- Create: `broadlinkmanager/web/src/components/ui/Badge.tsx`
- Create: `broadlinkmanager/web/src/components/ui/Button.tsx`
- Create: `broadlinkmanager/web/src/components/ui/StatusBanner.tsx`
- Create: `broadlinkmanager/web/src/components/ui/Toast.tsx`
- [ ] **Step 1: Create `web/src/components/ui/Badge.tsx`**
```tsx
interface BadgeProps {
variant?: 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'gray';
children: React.ReactNode;
dot?: boolean;
}
const variants = {
blue: 'bg-sky-500/10 text-sky-400 border border-sky-500/20',
green: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20',
red: 'bg-red-500/10 text-red-400 border border-red-500/20',
yellow: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
purple: 'bg-violet-500/10 text-violet-400 border border-violet-500/20',
gray: 'bg-slate-500/10 text-slate-400 border border-slate-500/20',
};
export function Badge({ variant = 'blue', children, dot }: BadgeProps) {
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${variants[variant]}`}>
{dot && <span className="w-1.5 h-1.5 rounded-full bg-current" />}
{children}
</span>
);
}
```
- [ ] **Step 2: Create `web/src/components/ui/Button.tsx`**
```tsx
import { type ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'ir' | 'rf';
size?: 'sm' | 'md';
}
const variants = {
primary: 'bg-sky-500 hover:bg-sky-400 text-white',
secondary: 'bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 dark:bg-slate-100 dark:hover:bg-slate-200 dark:text-slate-700 dark:border-slate-200',
ghost: 'bg-transparent hover:bg-slate-800 text-slate-400 dark:hover:bg-slate-100 dark:text-slate-500',
danger: 'bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20',
ir: 'bg-sky-500/10 hover:bg-sky-500/20 text-sky-400',
rf: 'bg-violet-500/10 hover:bg-violet-500/20 text-violet-400',
};
const sizes = {
sm: 'px-2.5 py-1 text-xs rounded-md',
md: 'px-3.5 py-1.5 text-sm rounded-lg',
};
export function Button({ variant = 'secondary', size = 'md', className = '', children, ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center gap-1.5 font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
```
- [ ] **Step 3: Create `web/src/components/ui/StatusBanner.tsx`**
```tsx
interface StatusBannerProps {
status: 'idle' | 'loading' | 'success' | 'error' | 'rf';
message: string;
}
const styles = {
idle: 'bg-slate-800 border-slate-700 text-slate-400',
loading: 'bg-sky-500/10 border-sky-500/20 text-sky-400',
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400',
error: 'bg-red-500/10 border-red-500/20 text-red-400',
rf: 'bg-violet-500/10 border-violet-500/20 text-violet-400',
};
const dotStyles = {
idle: 'bg-slate-500',
loading: 'bg-sky-400 animate-pulse',
success: 'bg-emerald-400',
error: 'bg-red-400 animate-pulse',
rf: 'bg-violet-400 animate-pulse',
};
export function StatusBanner({ status, message }: StatusBannerProps) {
return (
<div className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-sm ${styles[status]}`}>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dotStyles[status]}`} />
<span>{message}</span>
</div>
);
}
```
- [ ] **Step 4: Create `web/src/components/ui/Toast.tsx`**
```tsx
import { useEffect, useState } from 'react';
import { CheckCircle, XCircle, X } from 'lucide-react';
export type ToastType = 'success' | 'error';
export interface ToastMessage {
id: number;
type: ToastType;
message: string;
}
interface ToastProps {
toast: ToastMessage;
onDismiss: (id: number) => void;
}
function Toast({ toast, onDismiss }: ToastProps) {
useEffect(() => {
const t = setTimeout(() => onDismiss(toast.id), 3000);
return () => clearTimeout(t);
}, [toast.id, onDismiss]);
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl border text-sm font-medium
${toast.type === 'success'
? 'bg-slate-900 border-emerald-500/30 text-emerald-400'
: 'bg-slate-900 border-red-500/30 text-red-400'}`}
>
{toast.type === 'success' ? <CheckCircle size={16} /> : <XCircle size={16} />}
<span className="text-slate-200">{toast.message}</span>
<button onClick={() => onDismiss(toast.id)} className="ml-2 text-slate-500 hover:text-slate-300">
<X size={14} />
</button>
</div>
);
}
// Global toast state — simple singleton pattern for this app's scale
let _setToasts: React.Dispatch<React.SetStateAction<ToastMessage[]>> | null = null;
let _counter = 0;
export function addToast(type: ToastType, message: string) {
_setToasts?.(prev => [...prev, { id: ++_counter, type, message }]);
}
export function ToastContainer() {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
_setToasts = setToasts;
const dismiss = (id: number) => setToasts(prev => prev.filter(t => t.id !== id));
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map(t => <Toast key={t.id} toast={t} onDismiss={dismiss} />)}
</div>
);
}
```
- [ ] **Step 5: Commit**
```bash
git add broadlinkmanager/web/src/components/ui/
git commit -m "Add UI primitives: Badge, Button, StatusBanner, Toast"
```
---
### Task 12: Layout components (Sidebar, Topbar, AppShell)
**Files:**
- Create: `broadlinkmanager/web/src/components/layout/Sidebar.tsx`
- Create: `broadlinkmanager/web/src/components/layout/Topbar.tsx`
- Create: `broadlinkmanager/web/src/components/layout/AppShell.tsx`
- [ ] **Step 1: Create `web/src/components/layout/Sidebar.tsx`**
```tsx
import { NavLink } from 'react-router-dom';
import {
Monitor, BookMarked, Radio, Zap, ToggleLeft, RefreshCw,
ArrowLeftRight, Info, Sun, Moon, X,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
const NAV_ITEMS = [
{ to: '/', icon: Monitor, label: 'Devices', title: 'Devices' },
{ to: '/saved', icon: BookMarked, label: 'Saved Codes', title: 'Saved Codes' },
{ to: '/generator', icon: Radio, label: 'RF Generator', title: 'RF Code Generator' },
{ to: '/livolo', icon: ToggleLeft, label: 'Livolo', title: 'Livolo Code Generator' },
{ to: '/energenie', icon: Zap, label: 'Energenie', title: 'Energenie Generator' },
{ to: '/repeats', icon: RefreshCw, label: 'Repeats', title: 'Change Repeats' },
{ to: '/convert', icon: ArrowLeftRight,label: 'Convert', title: 'Hex ↔ Base64' },
{ to: '/about', icon: Info, label: 'About', title: 'About' },
];
interface SidebarProps {
mobileOpen: boolean;
onClose: () => void;
}
export function Sidebar({ mobileOpen, onClose }: SidebarProps) {
const { theme, toggleTheme } = useTheme();
const navContent = (
<>
{/* Logo */}
<div className="flex items-center justify-center h-12 mb-4 flex-shrink-0">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-sky-500 to-sky-400 flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-sky-500/20">
B
</div>
</div>
{/* Nav items */}
<nav className="flex-1 flex flex-col gap-0.5 overflow-y-auto">
{NAV_ITEMS.map(({ to, icon: Icon, label, title }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
title={title}
onClick={onClose}
className={({ isActive }) =>
`relative group flex items-center gap-3 px-2.5 py-2.5 rounded-xl transition-all duration-150 overflow-hidden
${isActive
? 'bg-sky-500/10 text-sky-400 before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.5 before:h-5 before:bg-sky-500 before:rounded-r'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-800/60 dark:hover:bg-slate-200/60'}`
}
>
<Icon size={18} className="flex-shrink-0" />
{/* Label: visible on mobile overlay, hidden on desktop collapsed sidebar, visible on hover */}
<span className="text-sm font-medium whitespace-nowrap
md:opacity-0 md:w-0 md:overflow-hidden
md:group-hover:opacity-100 md:group-hover:w-auto
lg:sidebar-expanded:opacity-100">
{label}
</span>
</NavLink>
))}
</nav>
{/* Theme toggle */}
<div className="flex-shrink-0 mt-auto pt-4 border-t border-slate-800 dark:border-slate-200">
<button
onClick={toggleTheme}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
className="w-full flex items-center gap-3 px-2.5 py-2.5 rounded-xl text-slate-500 hover:text-slate-300 hover:bg-slate-800/60 dark:hover:bg-slate-200/60 transition-all"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
<span className="text-sm font-medium md:hidden">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
</button>
</div>
</>
);
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:flex flex-col w-14 hover:w-52 transition-all duration-200 ease-in-out overflow-hidden
bg-slate-900 dark:bg-slate-50 border-r border-slate-800 dark:border-slate-200
flex-shrink-0 h-full px-2 py-3 group">
{navContent}
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div className="md:hidden fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<aside className="relative z-10 flex flex-col w-64 h-full bg-slate-900 dark:bg-slate-50
border-r border-slate-800 dark:border-slate-200 px-3 py-3">
<button onClick={onClose} className="absolute top-3 right-3 text-slate-500 hover:text-slate-300 p-1">
<X size={18} />
</button>
{navContent}
</aside>
</div>
)}
</>
);
}
```
- [ ] **Step 2: Create `web/src/components/layout/Topbar.tsx`**
```tsx
import { Menu } from 'lucide-react';
import type { ReactNode } from 'react';
interface TopbarProps {
title: string;
subtitle?: string;
actions?: ReactNode;
onMenuClick: () => void;
}
export function Topbar({ title, subtitle, actions, onMenuClick }: TopbarProps) {
return (
<header className="flex items-center gap-3 h-14 px-4 border-b border-slate-800 dark:border-slate-200 bg-slate-900 dark:bg-slate-50 flex-shrink-0">
<button
onClick={onMenuClick}
className="md:hidden p-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800"
aria-label="Open menu"
>
<Menu size={18} />
</button>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-semibold text-slate-100 dark:text-slate-900 truncate">{title}</h1>
{subtitle && <p className="text-xs text-slate-500 dark:text-slate-400 truncate">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</header>
);
}
```
- [ ] **Step 3: Create `web/src/components/layout/AppShell.tsx`**
```tsx
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
export function AppShell() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-slate-950 dark:bg-white text-slate-100 dark:text-slate-900">
<Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
<div className="flex flex-col flex-1 overflow-hidden">
<Outlet context={{ onMenuClick: () => setMobileOpen(true) }} />
</div>
</div>
);
}
```
- [ ] **Step 4: Commit**
```bash
git add broadlinkmanager/web/src/components/layout/
git commit -m "Add layout components: Sidebar, Topbar, AppShell"
```
---
### Task 13: App.tsx routing and main.tsx
**Files:**
- Create: `broadlinkmanager/web/src/App.tsx`
- Modify: `broadlinkmanager/web/src/main.tsx`
- [ ] **Step 1: Create `web/src/App.tsx`**
```tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { PanelProvider } from '@/contexts/PanelContext';
import { AppShell } from '@/components/layout/AppShell';
import { ToastContainer } from '@/components/ui/Toast';
import { DevicesPage } from '@/pages/DevicesPage';
import { SavedCodesPage } from '@/pages/SavedCodesPage';
import { GeneratorPage } from '@/pages/GeneratorPage';
import { LivoloPage } from '@/pages/LivoloPage';
import { EnergenieePage } from '@/pages/EnergenieePage';
import { RepeatsPage } from '@/pages/RepeatsPage';
import { ConvertPage } from '@/pages/ConvertPage';
import { AboutPage } from '@/pages/AboutPage';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 30_000 },
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<PanelProvider>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DevicesPage />} />
<Route path="/saved" element={<SavedCodesPage />} />
<Route path="/generator" element={<GeneratorPage />} />
<Route path="/livolo" element={<LivoloPage />} />
<Route path="/energenie" element={<EnergenieePage />} />
<Route path="/repeats" element={<RepeatsPage />} />
<Route path="/convert" element={<ConvertPage />} />
<Route path="/about" element={<AboutPage />} />
</Route>
</Routes>
</BrowserRouter>
<ToastContainer />
</PanelProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
```
- [ ] **Step 2: Replace `web/src/main.tsx`**
```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
```
- [ ] **Step 3: Create placeholder page stubs** (so the app compiles now; content added in later tasks)
```bash
cat > broadlinkmanager/web/src/pages/GeneratorPage.tsx << 'EOF'
export function GeneratorPage() { return <div className="p-6 text-slate-400">RF Generator — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/LivoloPage.tsx << 'EOF'
export function LivoloPage() { return <div className="p-6 text-slate-400">Livolo — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/EnergenieePage.tsx << 'EOF'
export function EnergenieePage() { return <div className="p-6 text-slate-400">Energenie — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/RepeatsPage.tsx << 'EOF'
export function RepeatsPage() { return <div className="p-6 text-slate-400">Repeats — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/ConvertPage.tsx << 'EOF'
export function ConvertPage() { return <div className="p-6 text-slate-400">Convert — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/AboutPage.tsx << 'EOF'
export function AboutPage() { return <div className="p-6 text-slate-400">About — coming in Task 17</div>; }
EOF
cat > broadlinkmanager/web/src/pages/SavedCodesPage.tsx << 'EOF'
export function SavedCodesPage() { return <div className="p-6 text-slate-400">Saved Codes — coming in Task 16</div>; }
EOF
cat > broadlinkmanager/web/src/pages/DevicesPage.tsx << 'EOF'
export function DevicesPage() { return <div className="p-6 text-slate-400">Devices — coming in Task 14</div>; }
EOF
```
- [ ] **Step 4: Verify the app compiles and runs**
```bash
cd broadlinkmanager/web && npm run build 2>&1 | tail -5
```
Expected: `✓ built in` (no errors)
- [ ] **Step 5: Commit**
```bash
git add broadlinkmanager/web/src/
git commit -m "Add App.tsx routing, main.tsx, and page stubs"
```
---
## Phase 3 — Pages
### Task 14: Devices page
**Files:**
- Modify: `broadlinkmanager/web/src/pages/DevicesPage.tsx`
- [ ] **Step 1: Write `web/src/pages/DevicesPage.tsx`**
```tsx
import { useOutletContext } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Save, Upload, Wifi, WifiOff, Thermometer } from 'lucide-react';
import { fetchDevices, saveDevices, loadDevices, pingDevice } from '@/api/devices';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { usePanel } from '@/contexts/PanelContext';
import { addToast } from '@/components/ui/Toast';
import { CommandPanel } from '@/components/commands/CommandPanel';
import type { Device } from '@/types';
function StatsRow({ total, online, codes, lastScan }: {
total: number; online: number; codes: number; lastScan: Date | null;
}) {
const age = lastScan ? Math.round((Date.now() - lastScan.getTime()) / 60000) : null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
{[
{ label: 'Total Devices', value: total, color: '' },
{ label: 'Online', value: online, color: 'text-emerald-400' },
{ label: 'Saved Codes', value: codes, color: 'text-sky-400' },
{ label: 'Last Scan', value: age !== null ? `${age}m ago` : '—', color: 'text-slate-400', small: true },
].map(s => (
<div key={s.label} className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-3.5">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1.5">{s.label}</div>
<div className={`font-bold ${s.small ? 'text-sm mt-1' : 'text-2xl'} ${s.color}`}>{s.value}</div>
</div>
))}
</div>
);
}
function DeviceRow({ device }: { device: Device }) {
const { openPanel } = usePanel();
const { data: pingData } = useQuery({
queryKey: ['ping', device.ip],
queryFn: () => pingDevice(device.ip),
refetchInterval: 30_000,
staleTime: 25_000,
});
const isOnline = pingData?.status === 'online';
const isOffline = pingData !== undefined && !isOnline;
return (
<tr
className={`border-b border-slate-800/50 dark:border-slate-200/50 hover:bg-slate-800/30 dark:hover:bg-slate-100/50 transition-colors cursor-pointer ${isOffline ? 'opacity-50' : ''}`}
onClick={() => openPanel(device, 'ir')}
>
<td className="px-4 py-3">
<div className="font-semibold text-sm text-slate-100 dark:text-slate-900">{device.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Universal Remote</div>
</td>
<td className="px-4 py-3">
<Badge variant="blue">{device.type}</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs text-slate-400">{device.ip}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-400 hidden md:table-cell">{device.mac}</td>
<td className="px-4 py-3">
{pingData === undefined ? (
<Badge variant="gray" dot>Checking…</Badge>
) : isOnline ? (
<Badge variant="green" dot>Online</Badge>
) : (
<Badge variant="gray" dot>Offline</Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
<Button size="sm" variant="ir" disabled={isOffline} onClick={() => openPanel(device, 'ir')}>
<Wifi size={11} /> IR
</Button>
<Button size="sm" variant="rf" disabled={isOffline} onClick={() => openPanel(device, 'rf')}>
IR RF
</Button>
<Button size="sm" variant="ghost" disabled={isOffline} onClick={() => openPanel(device, 'send')}>
Send
</Button>
<Button size="sm" variant="ghost" disabled={isOffline} onClick={() => openPanel(device, 'send')}
title="Read temperature">
<Thermometer size={11} />
</Button>
</div>
</td>
</tr>
);
}
export function DevicesPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const qc = useQueryClient();
const { isOpen } = usePanel();
const { data: devices = [], isFetching, dataUpdatedAt } = useQuery({
queryKey: ['devices'],
queryFn: () => fetchDevices(true),
staleTime: Infinity,
refetchOnWindowFocus: false,
});
const { data: codes = [] } = useQuery({
queryKey: ['codes'],
queryFn: async () => { const r = await fetch('/api/codes'); return r.json(); },
});
const rescan = () => qc.invalidateQueries({ queryKey: ['devices'] });
const saveMut = useMutation({
mutationFn: () => saveDevices(devices),
onSuccess: () => addToast('success', 'Devices saved to file'),
onError: () => addToast('error', 'Failed to save devices'),
});
const loadMut = useMutation({
mutationFn: loadDevices,
onSuccess: data => {
qc.setQueryData(['devices'], data);
addToast('success', `Loaded ${data.length} device(s) from file`);
},
onError: () => addToast('error', 'Failed to load devices'),
});
const onlineCount = devices.length;
const lastScan = dataUpdatedAt ? new Date(dataUpdatedAt) : null;
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar
title="Devices"
subtitle="Broadlink devices on your network"
onMenuClick={ctx.onMenuClick}
actions={
<>
<Button size="sm" variant="secondary" onClick={() => loadMut.mutate()} disabled={loadMut.isPending}>
<Upload size={12} /> Load
</Button>
<Button size="sm" variant="secondary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || !devices.length}>
<Save size={12} /> Save
</Button>
<Button size="sm" variant="primary" onClick={rescan} disabled={isFetching}>
<RefreshCw size={12} className={isFetching ? 'animate-spin' : ''} /> Rescan
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
<StatsRow
total={devices.length}
online={onlineCount}
codes={Array.isArray(codes) ? codes.length : 0}
lastScan={lastScan}
/>
{isFetching && (
<div className="flex items-center gap-3 px-3 py-2.5 mb-4 rounded-xl bg-sky-500/5 border border-sky-500/20 text-sky-400 text-sm">
<RefreshCw size={14} className="animate-spin flex-shrink-0" />
<span>Scanning network for Broadlink devices…</span>
</div>
)}
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Discovered Devices</span>
<Badge variant="blue" dot>{devices.length} device{devices.length !== 1 ? 's' : ''}</Badge>
</div>
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-800 dark:border-slate-200">
{['Device', 'Type', 'IP Address', 'MAC Address', 'Status', 'Actions'].map(h => (
<th key={h} className={`text-left text-xs font-medium text-slate-500 uppercase tracking-wider px-4 py-2.5 ${h === 'MAC Address' ? 'hidden md:table-cell' : ''}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{devices.length === 0 && !isFetching ? (
<tr>
<td colSpan={6} className="text-center text-slate-500 py-12 text-sm">
No devices found. Click <strong>Rescan</strong> to search your network.
</td>
</tr>
) : (
devices.map((d: Device) => <DeviceRow key={d.mac} device={d} />)
)}
</tbody>
</table>
</div>
</div>
{isOpen && <CommandPanel />}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/web/src/pages/DevicesPage.tsx
git commit -m "Add DevicesPage with device table, stats row, ping status, and scan progress"
```
---
### Task 15: Command Panel (IR, RF, Send tabs)
**Files:**
- Create: `broadlinkmanager/web/src/components/commands/CommandPanel.tsx`
- Create: `broadlinkmanager/web/src/components/commands/IrLearnTab.tsx`
- Create: `broadlinkmanager/web/src/components/commands/RfLearnTab.tsx`
- Create: `broadlinkmanager/web/src/components/commands/SendTab.tsx`
- [ ] **Step 1: Create `web/src/components/commands/IrLearnTab.tsx`**
```tsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy, Check } from 'lucide-react';
import { learnIr } from '@/api/commands';
import { createCode } from '@/api/codes';
import { StatusBanner } from '@/components/ui/StatusBanner';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
import type { Device } from '@/types';
export function IrLearnTab({ device }: { device: Device }) {
const qc = useQueryClient();
const [code, setCode] = useState('');
const [codeName, setCodeName] = useState('');
const [copied, setCopied] = useState(false);
const learnMut = useMutation({
mutationFn: () => learnIr(device.ip, device.mac, device.type),
onSuccess: data => {
if (data.success === 1) setCode(data.data);
else addToast('error', data.message || 'No signal received');
},
onError: () => addToast('error', 'IR learn request failed'),
});
const saveMut = useMutation({
mutationFn: () => createCode({ CodeType: 'IR', CodeName: codeName, Code: code }),
onSuccess: () => {
addToast('success', `Saved "${codeName}"`);
qc.invalidateQueries({ queryKey: ['codes'] });
setCode('');
setCodeName('');
},
onError: () => addToast('error', 'Failed to save code'),
});
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const status = learnMut.isPending ? 'loading'
: code ? 'success'
: learnMut.isError ? 'error'
: 'idle';
const statusMsg = learnMut.isPending ? 'Waiting for IR signal… Point remote and press a button.'
: code ? 'IR code captured.'
: learnMut.isError ? 'Failed — try again.'
: 'Click "Learn IR" then press a button on your remote.';
return (
<div className="flex flex-col gap-3 p-4">
<StatusBanner status={status} message={statusMsg} />
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Captured Code</label>
<div className="flex gap-2">
<input
readOnly
value={code}
placeholder="Code will appear here…"
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none"
/>
<button
onClick={copy}
disabled={!code}
className="w-9 h-9 rounded-lg bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 flex items-center justify-center text-slate-400 disabled:opacity-30"
>
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
</button>
</div>
</div>
{code && (
<div className="flex gap-2 items-center bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2">
<span className="text-xs text-slate-500 whitespace-nowrap">Save as:</span>
<select className="bg-slate-900 dark:bg-white border border-slate-700 dark:border-slate-200 rounded px-2 py-1 text-xs text-slate-300 dark:text-slate-700 outline-none">
<option>IR</option><option>RF</option>
</select>
<input
value={codeName}
onChange={e => setCodeName(e.target.value)}
placeholder="e.g. TV Power Off"
className="flex-1 bg-slate-900 dark:bg-white border border-slate-700 dark:border-slate-200 rounded px-2 py-1 text-xs text-slate-300 dark:text-slate-700 outline-none"
/>
<Button size="sm" variant="primary" disabled={!codeName || saveMut.isPending} onClick={() => saveMut.mutate()}>
Save
</Button>
</div>
)}
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="ir" onClick={() => learnMut.mutate()} disabled={learnMut.isPending}>
{learnMut.isPending ? 'Listening…' : code ? 'Learn Again' : 'Learn IR'}
</Button>
{code && (
<Button size="sm" variant="ghost" onClick={() => setCode('')}>Clear</Button>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: Create `web/src/components/commands/RfLearnTab.tsx`**
```tsx
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Copy, Check } from 'lucide-react';
import { learnRf, fetchRfStatus, continueRf } from '@/api/commands';
import { createCode } from '@/api/codes';
import { StatusBanner } from '@/components/ui/StatusBanner';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
import type { Device } from '@/types';
type Step = 0 | 1 | 2 | 3; // 0=idle, 1=hold, 2=press, 3=done
export function RfLearnTab({ device }: { device: Device }) {
const qc = useQueryClient();
const [step, setStep] = useState<Step>(0);
const [code, setCode] = useState('');
const [codeName, setCodeName] = useState('');
const [copied, setCopied] = useState(false);
const [isLearning, setIsLearning] = useState(false);
const { data: rfState } = useQuery({
queryKey: ['rf-status'],
queryFn: fetchRfStatus,
enabled: isLearning && step < 3,
refetchInterval: 1000,
onSuccess: data => {
if (data._rf_sweep_status === 'True' && step === 1) setStep(2);
},
} as any);
const learnMut = useMutation({
mutationFn: () => learnRf(device.ip, device.mac, device.type),
onMutate: () => { setIsLearning(true); setStep(1); },
onSuccess: data => {
if (data.success === 1) { setCode(data.data); setStep(3); }
else addToast('error', data.data || 'RF learn failed');
setIsLearning(false);
},
onError: () => { addToast('error', 'RF learn failed'); setIsLearning(false); setStep(0); },
});
const continueMut = useMutation({
mutationFn: continueRf,
onSuccess: () => setStep(2),
});
const saveMut = useMutation({
mutationFn: () => createCode({ CodeType: 'RF', CodeName: codeName, Code: code }),
onSuccess: () => {
addToast('success', `Saved "${codeName}"`);
qc.invalidateQueries({ queryKey: ['codes'] });
setCode(''); setCodeName(''); setStep(0);
},
onError: () => addToast('error', 'Failed to save code'),
});
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const statusMsg = rfState?._rf_sweep_message || (
step === 0 ? 'Click "Learn RF" to start.' :
step === 1 ? 'Hold the RF button until frequency is found…' :
step === 2 ? 'Press the button once to capture.' :
'RF code captured.'
);
const STEPS = [
{ label: 'Hold button', desc: 'Hold the RF button until device sweeps frequencies.' },
{ label: 'Press button once', desc: 'Press the same button once to capture the exact code.' },
{ label: 'Save code', desc: 'Name and save the captured RF code.' },
];
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-2">
{STEPS.map((s, i) => {
const done = step > i + 1;
const active = step === i + 1;
return (
<div key={i} className={`flex gap-2.5 items-start p-2.5 rounded-lg border transition-all
${done ? 'bg-emerald-500/5 border-emerald-500/20' :
active ? 'bg-violet-500/5 border-violet-500/20' :
'border-slate-800 dark:border-slate-200 opacity-40'}`}>
<div className={`w-5 h-5 rounded-full flex-shrink-0 flex items-center justify-center text-xs font-bold
${done ? 'bg-emerald-500 text-white' :
active ? 'bg-violet-500 text-white' :
'bg-slate-800 dark:bg-slate-200 text-slate-500'}`}>
{done ? '✓' : i + 1}
</div>
<div>
<div className="text-xs font-semibold text-slate-200 dark:text-slate-800">{s.label}</div>
<div className="text-xs text-slate-500">{s.desc}</div>
</div>
</div>
);
})}
</div>
<StatusBanner
status={step === 0 ? 'idle' : step === 3 ? 'success' : 'rf'}
message={statusMsg}
/>
{step === 3 && (
<>
<div className="flex gap-2">
<input readOnly value={code} className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none" />
<button onClick={copy} className="w-9 h-9 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400">
{copied ? <Check size={13} className="text-emerald-400" /> : <Copy size={13} />}
</button>
</div>
<div className="flex gap-2 items-center bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2">
<input value={codeName} onChange={e => setCodeName(e.target.value)} placeholder="e.g. Gate Open" className="flex-1 bg-transparent text-xs text-slate-300 dark:text-slate-700 outline-none" />
<Button size="sm" variant="primary" disabled={!codeName || saveMut.isPending} onClick={() => saveMut.mutate()}>Save</Button>
</div>
</>
)}
<div className="flex gap-2 flex-wrap">
{step === 0 && (
<Button size="sm" variant="rf" onClick={() => learnMut.mutate()} disabled={learnMut.isPending}>
Learn RF
</Button>
)}
{step === 2 && (
<Button size="sm" variant="rf" onClick={() => continueMut.mutate()} disabled={continueMut.isPending}>
Continue Sweep
</Button>
)}
{step > 0 && (
<Button size="sm" variant="ghost" onClick={() => { setStep(0); setCode(''); setIsLearning(false); }}>
Cancel
</Button>
)}
</div>
</div>
);
}
```
- [ ] **Step 3: Create `web/src/components/commands/SendTab.tsx`**
```tsx
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Send } from 'lucide-react';
import { sendCommand } from '@/api/commands';
import { fetchAllCodes } from '@/api/codes';
import { StatusBanner } from '@/components/ui/StatusBanner';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { addToast } from '@/components/ui/Toast';
import type { Device, Code } from '@/types';
export function SendTab({ device, prefilledCode = '' }: { device: Device; prefilledCode?: string }) {
const [code, setCode] = useState(prefilledCode);
const [lastResult, setLastResult] = useState<'success' | 'error' | null>(null);
const [filter, setFilter] = useState('');
const { data: codes = [] } = useQuery({ queryKey: ['codes'], queryFn: fetchAllCodes });
const sendMut = useMutation({
mutationFn: (c: string) => sendCommand(device.ip, device.mac, device.type, c),
onSuccess: data => {
if (data.success === 1) { setLastResult('success'); addToast('success', 'Command sent'); }
else { setLastResult('error'); addToast('error', data.message || 'Send failed'); }
},
onError: () => { setLastResult('error'); addToast('error', 'Send failed'); },
});
const filtered = (codes as Code[]).filter(c =>
!filter || c.CodeName.toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Paste Code (Base64 or Hex)</label>
<div className="flex gap-2">
<input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="JgBIAAABK5QQ..."
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500"
/>
<Button size="sm" variant="primary" disabled={!code || sendMut.isPending} onClick={() => sendMut.mutate(code)}>
<Send size={12} /> Send
</Button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-slate-600">
<div className="flex-1 h-px bg-slate-800 dark:bg-slate-200" />
or pick from saved
<div className="flex-1 h-px bg-slate-800 dark:bg-slate-200" />
</div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter codes…"
className="bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-1.5 text-xs text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500"
/>
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-xs text-slate-500 text-center py-4">No saved codes yet.</div>
) : filtered.map((c: Code) => (
<div key={c.CodeId} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 dark:bg-slate-100 border border-slate-800 dark:border-slate-200 hover:border-sky-500/30 transition-colors">
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: c.CodeType === 'RF' ? '#a78bfa' : '#38bdf8' }} />
<span className="flex-1 text-xs text-slate-300 dark:text-slate-700 truncate">{c.CodeName}</span>
<Badge variant={c.CodeType === 'RF' ? 'purple' : 'blue'}>{c.CodeType}</Badge>
<Button size="sm" variant={c.CodeType === 'RF' ? 'rf' : 'ir'} disabled={sendMut.isPending}
onClick={() => sendMut.mutate(c.Code)}>
Send
</Button>
</div>
))}
</div>
{lastResult && (
<StatusBanner
status={lastResult}
message={lastResult === 'success' ? `Command sent to ${device.name}` : 'Send failed — check device is online'}
/>
)}
</div>
);
}
```
- [ ] **Step 4: Create `web/src/components/commands/CommandPanel.tsx`**
```tsx
import { X } from 'lucide-react';
import { usePanel } from '@/contexts/PanelContext';
import { IrLearnTab } from './IrLearnTab';
import { RfLearnTab } from './RfLearnTab';
import { SendTab } from './SendTab';
type Tab = 'ir' | 'rf' | 'send';
const TABS: { id: Tab; label: string }[] = [
{ id: 'ir', label: 'IR Code' },
{ id: 'rf', label: 'RF Code' },
{ id: 'send', label: 'Send' },
];
export function CommandPanel() {
const { device, activeTab, setTab, closePanel, prefilledCode } = usePanel();
if (!device) return null;
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/40 z-30" onClick={closePanel} />
{/* Panel */}
<div className="fixed top-0 right-0 h-full w-full max-w-sm z-40 flex flex-col
bg-slate-900 dark:bg-slate-50 border-l border-slate-800 dark:border-slate-200 shadow-2xl">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-slate-800 dark:border-slate-200 flex-shrink-0">
<div className="w-9 h-9 rounded-xl bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400 text-sm">
📡
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-100 dark:text-slate-900">{device.name}</div>
<div className="text-xs font-mono text-slate-500">{device.ip} · {device.mac}</div>
</div>
<button onClick={closePanel} className="p-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 dark:hover:bg-slate-200 transition-colors">
<X size={16} />
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 px-3 py-2.5 border-b border-slate-800 dark:border-slate-200 bg-slate-950/50 dark:bg-slate-100/50 flex-shrink-0">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all
${activeTab === t.id
? t.id === 'rf' ? 'bg-violet-500 text-white' : 'bg-sky-500 text-white'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-800 dark:hover:bg-slate-200'}`}
>
{t.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'ir' && <IrLearnTab device={device} />}
{activeTab === 'rf' && <RfLearnTab device={device} />}
{activeTab === 'send' && <SendTab device={device} prefilledCode={prefilledCode} />}
</div>
</div>
</>
);
}
```
- [ ] **Step 5: Commit**
```bash
git add broadlinkmanager/web/src/components/commands/
git commit -m "Add CommandPanel with IR learn, RF sweep step-tracker, and Send tabs"
```
---
### Task 16: Saved Codes page
**Files:**
- Modify: `broadlinkmanager/web/src/pages/SavedCodesPage.tsx`
- [ ] **Step 1: Write `web/src/pages/SavedCodesPage.tsx`**
```tsx
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Edit2, Send, Download } from 'lucide-react';
import { fetchAllCodes, createCode, updateCode, deleteCode } from '@/api/codes';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { addToast } from '@/components/ui/Toast';
import { usePanel } from '@/contexts/PanelContext';
import type { Code, CodeInput } from '@/types';
const PAGE_SIZE = 10;
interface CodeFormProps {
initial?: Code;
onSave: (data: CodeInput) => void;
onCancel: () => void;
saving: boolean;
}
function CodeForm({ initial, onSave, onCancel, saving }: CodeFormProps) {
const [type, setType] = useState(initial?.CodeType ?? 'IR');
const [name, setName] = useState(initial?.CodeName ?? '');
const [code, setCode] = useState(initial?.Code ?? '');
return (
<div className="flex flex-col gap-3 p-4 bg-slate-800/50 dark:bg-slate-100 rounded-xl border border-slate-700 dark:border-slate-200">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Type</label>
<select value={type} onChange={e => setType(e.target.value)}
className="w-full bg-slate-900 dark:bg-white border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-1.5 text-sm text-slate-200 dark:text-slate-800 outline-none">
<option>IR</option><option>RF</option>
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Name</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. TV Power"
className="w-full bg-slate-900 dark:bg-white border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-1.5 text-sm text-slate-200 dark:text-slate-800 outline-none focus:border-sky-500" />
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Code (Base64 or Hex)</label>
<textarea value={code} onChange={e => setCode(e.target.value)} rows={2} placeholder="JgBIAAABK5QQ…"
className="w-full bg-slate-900 dark:bg-white border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-1.5 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500 resize-none" />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
<Button size="sm" variant="primary" disabled={!name || !code || saving}
onClick={() => onSave({ CodeType: type, CodeName: name, Code: code })}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
);
}
export function SavedCodesPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const qc = useQueryClient();
const { openPanel } = usePanel();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'IR' | 'RF'>('all');
const [page, setPage] = useState(0);
const [showAdd, setShowAdd] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const { data: codes = [] } = useQuery({ queryKey: ['codes'], queryFn: fetchAllCodes });
const createMut = useMutation({
mutationFn: createCode,
onSuccess: () => { addToast('success', 'Code saved'); qc.invalidateQueries({ queryKey: ['codes'] }); setShowAdd(false); },
onError: () => addToast('error', 'Failed to save code'),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: CodeInput }) => updateCode(id, data),
onSuccess: () => { addToast('success', 'Code updated'); qc.invalidateQueries({ queryKey: ['codes'] }); setEditId(null); },
onError: () => addToast('error', 'Failed to update code'),
});
const deleteMut = useMutation({
mutationFn: deleteCode,
onSuccess: () => { addToast('success', 'Code deleted'); qc.invalidateQueries({ queryKey: ['codes'] }); },
onError: () => addToast('error', 'Failed to delete code'),
});
const exportCsv = () => {
const rows = [['CodeId', 'CodeType', 'CodeName', 'Code'], ...(codes as Code[]).map(c => [c.CodeId, c.CodeType, c.CodeName, c.Code])];
const csv = rows.map(r => r.map(String).join(',')).join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'broadlink-codes.csv';
a.click();
};
const filtered = (codes as Code[]).filter(c => {
const matchType = typeFilter === 'all' || c.CodeType === typeFilter;
const matchSearch = !search || c.CodeName.toLowerCase().includes(search.toLowerCase());
return matchType && matchSearch;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar
title="Saved Codes"
subtitle={`${(codes as Code[]).length} IR/RF codes`}
onMenuClick={ctx.onMenuClick}
actions={
<>
<Button size="sm" variant="secondary" onClick={exportCsv}><Download size={12} /> Export CSV</Button>
<Button size="sm" variant="primary" onClick={() => setShowAdd(true)}><Plus size={12} /> Add Code</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
{showAdd && (
<div className="mb-4">
<CodeForm onSave={d => createMut.mutate(d)} onCancel={() => setShowAdd(false)} saving={createMut.isPending} />
</div>
)}
<div className="flex gap-2 mb-4 flex-wrap">
<input value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}
placeholder="Search codes…"
className="flex-1 min-w-0 bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-lg px-3 py-1.5 text-sm text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500" />
{(['all', 'IR', 'RF'] as const).map(t => (
<button key={t} onClick={() => { setTypeFilter(t); setPage(0); }}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all
${typeFilter === t ? 'bg-sky-500 text-white' : 'bg-slate-800 dark:bg-slate-200 text-slate-400 dark:text-slate-600'}`}>
{t === 'all' ? 'All' : t}
</button>
))}
</div>
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-800 dark:border-slate-200">
{['#', 'Name', 'Type', 'Code Preview', 'Actions'].map(h => (
<th key={h} className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider px-4 py-2.5">{h}</th>
))}
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr><td colSpan={5} className="text-center text-slate-500 py-12 text-sm">No codes found.</td></tr>
) : paged.map((c: Code) => (
editId === c.CodeId ? (
<tr key={c.CodeId} className="border-b border-slate-800/50">
<td colSpan={5} className="px-4 py-3">
<CodeForm
initial={c}
onSave={d => updateMut.mutate({ id: c.CodeId, data: d })}
onCancel={() => setEditId(null)}
saving={updateMut.isPending}
/>
</td>
</tr>
) : (
<tr key={c.CodeId} className="border-b border-slate-800/50 dark:border-slate-200/50 hover:bg-slate-800/20 dark:hover:bg-slate-100/50 transition-colors">
<td className="px-4 py-3 text-xs text-slate-500">{c.CodeId}</td>
<td className="px-4 py-3 text-sm font-semibold text-slate-100 dark:text-slate-900">{c.CodeName}</td>
<td className="px-4 py-3"><Badge variant={c.CodeType === 'RF' ? 'purple' : 'blue'}>{c.CodeType}</Badge></td>
<td className="px-4 py-3 font-mono text-xs text-slate-500 max-w-[160px] truncate">{c.Code.slice(0, 30)}…</td>
<td className="px-4 py-3">
<div className="flex gap-1.5">
<Button size="sm" variant="ir" onClick={() => openPanel({ name: '', type: '', ip: '', mac: '' }, 'send', c.Code)}>
<Send size={11} />
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditId(c.CodeId)}><Edit2 size={11} /></Button>
<Button size="sm" variant="danger" onClick={() => deleteMut.mutate(c.CodeId)}><Trash2 size={11} /></Button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-2.5 border-t border-slate-800 dark:border-slate-200">
<span className="text-xs text-slate-500">{filtered.length} codes</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Prev</Button>
{Array.from({ length: totalPages }, (_, i) => (
<button key={i} onClick={() => setPage(i)}
className={`w-7 h-7 rounded-lg text-xs font-medium transition-all
${page === i ? 'bg-sky-500 text-white' : 'text-slate-500 hover:bg-slate-800 dark:hover:bg-slate-200'}`}>
{i + 1}
</button>
))}
<Button size="sm" variant="ghost" disabled={page === totalPages - 1} onClick={() => setPage(p => p + 1)}>Next →</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add broadlinkmanager/web/src/pages/SavedCodesPage.tsx
git commit -m "Add SavedCodesPage with search, filter, CRUD, pagination, and CSV export"
```
---
### Task 17: Simple form pages (Generator, Livolo, Energenie, Repeats, Convert, About)
**Files:**
- Modify: `broadlinkmanager/web/src/pages/GeneratorPage.tsx`
- Modify: `broadlinkmanager/web/src/pages/LivoloPage.tsx`
- Modify: `broadlinkmanager/web/src/pages/EnergenieePage.tsx`
- Modify: `broadlinkmanager/web/src/pages/RepeatsPage.tsx`
- Modify: `broadlinkmanager/web/src/pages/ConvertPage.tsx`
- Modify: `broadlinkmanager/web/src/pages/AboutPage.tsx`
- Create: `broadlinkmanager/web/src/lib/codegen.ts`
- [ ] **Step 1: Create `web/src/lib/codegen.ts`** (ports all the existing JS code generation logic)
```typescript
// ── Shared helpers ──────────────────────────────────────────────────────────
export function hexToBase64(hex: string): string {
const clean = hex.replace(/\s/g, '');
const bytes = clean.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) ?? [];
return btoa(String.fromCharCode(...bytes));
}
export function base64ToHex(b64: string): string {
const bin = atob(b64);
return Array.from(bin).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
}
// ── RF Generator (ports generator.js) ───────────────────────────────────────
const HIGH_BIT = '240d';
const LOW_BIT = '0d24';
const FOOTER = '0c00016f00000000';
const BYTES = 24;
export function generateRfCode(type: 'RF433' | 'RF315'): { regular: string; long: string } {
const prefix = type === 'RF433' ? 'b2' : 'd7';
let code = '';
for (let i = 0; i < BYTES; i++) code += Math.random() < 0.5 ? HIGH_BIT : LOW_BIT;
const regular = `${prefix}0c3400${code}${FOOTER}`;
const long = `${prefix}5c3400${code}${FOOTER}`;
return { regular: hexToBase64(regular), long: hexToBase64(long) };
}
export function changeRepeats(b64: string, newRepeats: number): string {
const hex = base64ToHex(b64).replace(/\s/g, '');
const repeatHex = newRepeats.toString(16).padStart(2, '0');
const modified = hex.slice(0, 2) + repeatHex + hex.slice(4);
return hexToBase64(modified);
}
// ── Livolo (remote ID + button code → RF) ───────────────────────────────────
export function generateLivoloCode(remoteId: number, buttonCode: number): string {
const LIVOLO_HIGH = '0011';
const LIVOLO_LOW = '0006';
const PREFIX = 'd700';
function toBits(n: number, bits: number): string {
return n.toString(2).padStart(bits, '0');
}
const idBits = toBits(remoteId, 16);
const btnBits = toBits(buttonCode, 8);
let payload = '';
for (const bit of idBits + btnBits) payload += bit === '1' ? LIVOLO_HIGH : LIVOLO_LOW;
return hexToBase64(PREFIX + payload + '0003');
}
// ── Energenie (Type D RC sockets, ports the template's inline JS) ────────────
const E_HIGH = '1507';
const E_LOW = '0815';
const E_FOOTER = '08dc000000000000';
const E_PREFIX = 'b2';
const E_REPEATS = '08';
const E_LEN = '2000';
const SOCKET_CMDS: Record<number, string> = {
// ON codes (4-bit group for each socket, per Energenie protocol)
1: '11101110', 2: '11100111', 3: '11100000', 4: '11100000',
};
export function generateEnergeniCode(socket: 1 | 2 | 3 | 4, on: boolean): string {
// 20 random remote-group bits
let remoteBits = '';
for (let i = 0; i < 20; i++) remoteBits += Math.random() < 0.5 ? '1' : '0';
// 4-bit socket command
const socketBit = (socket - 1).toString(2).padStart(2, '0');
const onBit = on ? '1' : '0';
const cmdBits = socketBit + onBit + '0';
let payload = '';
for (const bit of remoteBits + cmdBits) payload += bit === '1' ? E_HIGH : E_LOW;
return hexToBase64(`${E_PREFIX}${E_REPEATS}${E_LEN}${payload}${E_FOOTER}`);
}
```
- [ ] **Step 2: Write `web/src/pages/GeneratorPage.tsx`**
```tsx
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy, RefreshCw } from 'lucide-react';
import { generateRfCode } from '@/lib/codegen';
import { createCode } from '@/api/codes';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
export function GeneratorPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const qc = useQueryClient();
const [type, setType] = useState<'RF433' | 'RF315'>('RF433');
const [result, setResult] = useState<{ regular: string; long: string } | null>(null);
const [codeName, setCodeName] = useState('');
const saveMut = useMutation({
mutationFn: (code: string) => createCode({ CodeType: 'RF', CodeName: codeName, Code: code }),
onSuccess: () => { addToast('success', `Saved "${codeName}"`); qc.invalidateQueries({ queryKey: ['codes'] }); setCodeName(''); },
onError: () => addToast('error', 'Failed to save'),
});
const generate = () => setResult(generateRfCode(type));
const copy = (text: string) => { navigator.clipboard.writeText(text); addToast('success', 'Copied'); };
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="RF Code Generator" subtitle="Generate random 433/315 MHz RF codes" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4 max-w-2xl">
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5 flex flex-col gap-4">
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Frequency</label>
<div className="flex gap-2">
{(['RF433', 'RF315'] as const).map(t => (
<button key={t} onClick={() => setType(t)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${type === t ? 'bg-sky-500 text-white' : 'bg-slate-800 dark:bg-slate-200 text-slate-400 dark:text-slate-600'}`}>
{t === 'RF433' ? '433 MHz' : '315 MHz'}
</button>
))}
</div>
</div>
<Button variant="primary" onClick={generate}>
<RefreshCw size={14} /> Generate Code
</Button>
{result && (
<div className="flex flex-col gap-3">
{[{ label: 'Regular Repeat', code: result.regular }, { label: 'Long Repeat', code: result.long }].map(r => (
<div key={r.label}>
<div className="text-xs text-slate-500 mb-1">{r.label}</div>
<div className="flex gap-2">
<input readOnly value={r.code} className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none" />
<button onClick={() => copy(r.code)} className="w-9 h-9 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400">
<Copy size={13} />
</button>
</div>
</div>
))}
<div className="flex gap-2 items-center">
<input value={codeName} onChange={e => setCodeName(e.target.value)} placeholder="Save as…"
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-300 dark:text-slate-700 outline-none" />
<Button size="sm" variant="primary" disabled={!codeName || saveMut.isPending}
onClick={() => saveMut.mutate(result.regular)}>
Save
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
```
- [ ] **Step 3: Write `web/src/pages/LivoloPage.tsx`**
```tsx
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy } from 'lucide-react';
import { generateLivoloCode } from '@/lib/codegen';
import { createCode } from '@/api/codes';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
const BUTTON_OPTIONS = [
{ value: 90, label: 'On only (Scene 1)' },
{ value: 40, label: 'On/Off toggle' },
{ value: 106, label: 'Off only' },
];
export function LivoloPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const qc = useQueryClient();
const [remoteId, setRemoteId] = useState('7400');
const [buttonCode, setButtonCode] = useState(90);
const [result, setResult] = useState('');
const [codeName, setCodeName] = useState('');
const saveMut = useMutation({
mutationFn: () => createCode({ CodeType: 'RF', CodeName: codeName, Code: result }),
onSuccess: () => { addToast('success', `Saved "${codeName}"`); qc.invalidateQueries({ queryKey: ['codes'] }); setCodeName(''); },
onError: () => addToast('error', 'Failed to save'),
});
const generate = () => {
const id = parseInt(remoteId, 10);
if (isNaN(id) || id < 1 || id > 65500) { addToast('error', 'Remote ID must be 1–65500'); return; }
setResult(generateLivoloCode(id, buttonCode));
};
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="Livolo Code Generator" subtitle="Generate RF codes for Livolo switches" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4 max-w-2xl">
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5 flex flex-col gap-4">
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Remote ID (1–65500, divisible by 4)</label>
<input value={remoteId} onChange={e => setRemoteId(e.target.value)} placeholder="7400"
className="w-full bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-200 dark:text-slate-800 outline-none focus:border-sky-500" />
</div>
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Button</label>
<select value={buttonCode} onChange={e => setButtonCode(Number(e.target.value))}
className="w-full bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-200 dark:text-slate-800 outline-none">
{BUTTON_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<Button variant="primary" onClick={generate}>Generate</Button>
{result && (
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<input readOnly value={result} className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none" />
<button onClick={() => { navigator.clipboard.writeText(result); addToast('success', 'Copied'); }}
className="w-9 h-9 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400">
<Copy size={13} />
</button>
</div>
<div className="flex gap-2">
<input value={codeName} onChange={e => setCodeName(e.target.value)} placeholder="Save as…"
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-300 dark:text-slate-700 outline-none" />
<Button size="sm" variant="primary" disabled={!codeName || saveMut.isPending} onClick={() => saveMut.mutate()}>Save</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
```
- [ ] **Step 4: Write `web/src/pages/EnergenieePage.tsx`**
```tsx
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy } from 'lucide-react';
import { generateEnergeniCode } from '@/lib/codegen';
import { createCode } from '@/api/codes';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
export function EnergenieePage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const qc = useQueryClient();
const [socket, setSocket] = useState<1 | 2 | 3 | 4>(1);
const [on, setOn] = useState(true);
const [result, setResult] = useState('');
const [codeName, setCodeName] = useState('');
const saveMut = useMutation({
mutationFn: () => createCode({ CodeType: 'RF', CodeName: codeName, Code: result }),
onSuccess: () => { addToast('success', `Saved "${codeName}"`); qc.invalidateQueries({ queryKey: ['codes'] }); setCodeName(''); },
onError: () => addToast('error', 'Failed to save'),
});
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="Energenie Code Generator" subtitle="Generate codes for Energenie Type-D RF sockets" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4 max-w-2xl">
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5 flex flex-col gap-4">
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Socket Number</label>
<div className="flex gap-2">
{([1, 2, 3, 4] as const).map(n => (
<button key={n} onClick={() => setSocket(n)}
className={`w-12 h-10 rounded-lg text-sm font-bold transition-all ${socket === n ? 'bg-sky-500 text-white' : 'bg-slate-800 dark:bg-slate-200 text-slate-400 dark:text-slate-600'}`}>
{n}
</button>
))}
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Command</label>
<div className="flex gap-2">
{[true, false].map(v => (
<button key={String(v)} onClick={() => setOn(v)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${on === v ? (v ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white') : 'bg-slate-800 dark:bg-slate-200 text-slate-400 dark:text-slate-600'}`}>
{v ? 'Turn On' : 'Turn Off'}
</button>
))}
</div>
</div>
<Button variant="primary" onClick={() => setResult(generateEnergeniCode(socket, on))}>Generate</Button>
{result && (
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<input readOnly value={result} className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none" />
<button onClick={() => { navigator.clipboard.writeText(result); addToast('success', 'Copied'); }}
className="w-9 h-9 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400">
<Copy size={13} />
</button>
</div>
<div className="flex gap-2">
<input value={codeName} onChange={e => setCodeName(e.target.value)} placeholder="Save as…"
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-300 dark:text-slate-700 outline-none" />
<Button size="sm" variant="primary" disabled={!codeName || saveMut.isPending} onClick={() => saveMut.mutate()}>Save</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
```
- [ ] **Step 5: Write `web/src/pages/RepeatsPage.tsx`**
```tsx
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Copy } from 'lucide-react';
import { changeRepeats } from '@/lib/codegen';
import { Topbar } from '@/components/layout/Topbar';
import { Button } from '@/components/ui/Button';
import { addToast } from '@/components/ui/Toast';
export function RepeatsPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const [input, setInput] = useState('');
const [repeats, setRepeats] = useState('12');
const [result, setResult] = useState('');
const process = () => {
try {
const n = parseInt(repeats, 10);
if (isNaN(n) || n < 1 || n > 255) { addToast('error', 'Repeats must be 1–255'); return; }
setResult(changeRepeats(input.trim(), n));
} catch {
addToast('error', 'Invalid Base64 code');
}
};
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="Change Repeats" subtitle="Modify the repeat count in an existing code" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4 max-w-2xl">
<div className="bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5 flex flex-col gap-4">
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Existing Code (Base64)</label>
<textarea value={input} onChange={e => setInput(e.target.value)} rows={3}
placeholder="Paste your existing Base64 code here…"
className="w-full bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500 resize-none" />
</div>
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">New Repeat Count (1–255)</label>
<input value={repeats} onChange={e => setRepeats(e.target.value)} type="number" min={1} max={255}
className="w-32 bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-200 dark:text-slate-800 outline-none focus:border-sky-500" />
</div>
<Button variant="primary" disabled={!input} onClick={process}>Update Repeats</Button>
{result && (
<div>
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Result</label>
<div className="flex gap-2">
<textarea readOnly value={result} rows={3}
className="flex-1 bg-slate-800 dark:bg-slate-100 border border-slate-700 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none resize-none" />
<button onClick={() => { navigator.clipboard.writeText(result); addToast('success', 'Copied'); }}
className="w-9 h-9 rounded-lg bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400 self-start">
<Copy size={13} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
```
- [ ] **Step 6: Write `web/src/pages/ConvertPage.tsx`**
```tsx
import { useOutletContext } from 'react-router-dom';
import { hexToBase64, base64ToHex } from '@/lib/codegen';
import { Topbar } from '@/components/layout/Topbar';
import { Copy } from 'lucide-react';
import { addToast } from '@/components/ui/Toast';
import { useState } from 'react';
function ConvertPanel({ label, input, output, onChange }: {
label: string; input: string; output: string; onChange: (v: string) => void;
}) {
return (
<div className="flex-1 flex flex-col gap-2">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider">{label}</label>
<textarea value={input} onChange={e => onChange(e.target.value)} rows={5} placeholder="Paste here…"
className="w-full bg-slate-800 dark:bg-slate-100 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-300 dark:text-slate-700 outline-none focus:border-sky-500 resize-none" />
{output && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-slate-500 uppercase tracking-wider">Result</label>
<button onClick={() => { navigator.clipboard.writeText(output); addToast('success', 'Copied'); }}
className="p-1 rounded text-slate-500 hover:text-slate-300"><Copy size={12} /></button>
</div>
<textarea readOnly value={output} rows={5}
className="w-full bg-slate-800/50 dark:bg-slate-50 border border-slate-700 dark:border-slate-200 rounded-lg px-3 py-2 text-xs font-mono text-slate-400 dark:text-slate-600 outline-none resize-none" />
</div>
)}
</div>
);
}
export function ConvertPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const [hex, setHex] = useState('');
const [b64, setB64] = useState('');
const hexOut = hex ? (() => { try { return hexToBase64(hex); } catch { return 'Invalid hex'; } })() : '';
const b64Out = b64 ? (() => { try { return base64ToHex(b64); } catch { return 'Invalid Base64'; } })() : '';
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="Convert Hex ↔ Base64" subtitle="Live conversion between formats" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4">
<div className="flex flex-col md:flex-row gap-4 max-w-4xl">
<div className="flex-1 bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5">
<ConvertPanel label="Hex → Base64" input={hex} output={hexOut} onChange={setHex} />
</div>
<div className="flex-1 bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-5">
<ConvertPanel label="Base64 → Hex" input={b64} output={b64Out} onChange={setB64} />
</div>
</div>
</div>
</div>
);
}
```
- [ ] **Step 7: Write `web/src/pages/AboutPage.tsx`**
```tsx
import { useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Github, Coffee } from 'lucide-react';
import { Topbar } from '@/components/layout/Topbar';
export function AboutPage() {
const ctx = useOutletContext<{ onMenuClick: () => void }>();
const { data } = useQuery({
queryKey: ['version'],
queryFn: async () => { const r = await fetch('/api/version'); return r.json(); },
staleTime: Infinity,
});
return (
<div className="flex flex-col flex-1 overflow-hidden">
<Topbar title="About" subtitle="Broadlink Manager" onMenuClick={ctx.onMenuClick} />
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-lg bg-slate-900 dark:bg-slate-50 border border-slate-800 dark:border-slate-200 rounded-xl p-6 flex flex-col gap-5">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-sky-500 to-sky-400 flex items-center justify-center text-white font-bold text-2xl shadow-lg shadow-sky-500/20">
B
</div>
<div>
<h2 className="text-lg font-bold text-slate-100 dark:text-slate-900">Broadlink Manager</h2>
<p className="text-sm text-slate-500">Version {data?.version ?? '…'}</p>
</div>
</div>
<p className="text-sm text-slate-400 dark:text-slate-600 leading-relaxed">
A web-based manager for Broadlink IR/RF devices. Discover devices on your network, learn and send IR and RF codes, manage your code library, and generate codes for supported third-party devices.
</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/t0mer/broadlinkmanager-docker" target="_blank" rel="noopener"
className="flex items-center gap-2 text-sm text-sky-400 hover:text-sky-300 transition-colors">
<Github size={16} /> github.com/t0mer/broadlinkmanager-docker
</a>
<span className="text-xs text-slate-500">By Tomer Klein · tomer.klein@gmail.com</span>
</div>
<div className="border-t border-slate-800 dark:border-slate-200 pt-4">
<p className="text-xs text-slate-500 mb-3">Like this project? Buy me a coffee ☕</p>
<form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_blank">
<input type="hidden" name="cmd" value="_s-xclick" />
<input type="hidden" name="hosted_button_id" value="G3LNNZEUT8WLW" />
<button type="submit" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 text-sm font-medium hover:bg-amber-500/20 transition-colors">
<Coffee size={14} /> Donate via PayPal
</button>
</form>
</div>
</div>
</div>
</div>
);
}
```
- [ ] **Step 8: Commit**
```bash
git add broadlinkmanager/web/src/lib/ broadlinkmanager/web/src/pages/
git commit -m "Add all pages: Generator, Livolo, Energenie, Repeats, Convert, About + codegen lib"
```
---
## Phase 4 — Build Integration
### Task 18: Vite build → dist/ + FastAPI SPA fallback
**Files:**
- Modify: `broadlinkmanager/app/main.py` (already handles dist/ if it exists)
- [ ] **Step 1: Do a production build and verify FastAPI serves it**
```bash
cd broadlinkmanager/web && npm run build && cd ..
python broadlinkmanager.py &
sleep 3
curl -s http://localhost:7020/ | head -3
curl -s http://localhost:7020/api/version
curl -s http://localhost:7020/saved | head -3
kill %1
cd ..
```
Expected: first two commands return HTML with `<!doctype html>` and `{"version":"6.0.0"}`;
`/saved` also returns the `index.html` (SPA fallback working).
- [ ] **Step 2: Commit the built dist/ placeholder**
```bash
mkdir -p broadlinkmanager/dist
touch broadlinkmanager/dist/.gitkeep
git add broadlinkmanager/dist/.gitkeep
git commit -m "Add dist/.gitkeep placeholder for embedded frontend build"
```
---
### Task 19: Update .gitignore
**Files:**
- Modify: `.gitignore` (root)
- [ ] **Step 1: Add the required entries to `.gitignore`**
Read the current `.gitignore` first:
```bash
cat .gitignore 2>/dev/null || echo "(no .gitignore yet)"
```
Then add these entries (append to existing file or create if missing):
```
# Python
broadlinkmanager/__pycache__/
broadlinkmanager/**/__pycache__/
broadlinkmanager/**/*.pyc
# Frontend build output (kept as placeholder only)
broadlinkmanager/dist/*
!broadlinkmanager/dist/.gitkeep
# Node
broadlinkmanager/web/node_modules/
broadlinkmanager/web/dist/
# Superpowers / design artifacts
.superpowers/
docs/
# Brainstorm scripts (temp)
modernize-templates.sh
update-all-templates.sh
update-ui.sh
```
- [ ] **Step 2: Commit**
```bash
git add .gitignore
git commit -m "Update .gitignore: exclude dist/*, node_modules/, __pycache__, .superpowers/"
```
---
### Task 20: Dockerfile multi-stage build
**Files:**
- Modify: `Dockerfile`
- [ ] **Step 1: Read the current Dockerfile**
```bash
cat Dockerfile
```
- [ ] **Step 2: Replace with multi-stage Dockerfile**
The key change: add a Node stage that builds the React app before the Python runtime stage, and copy `dist/` from the frontend stage.
```dockerfile
# Stage 1 — Build React frontend
FROM node:20-alpine AS frontend
WORKDIR /app/web
COPY broadlinkmanager/web/package*.json ./
RUN npm ci --silent
COPY broadlinkmanager/web/ ./
RUN npm run build
# Output is in /app/web/../dist → /app/dist (vite outDir: '../dist')
# Stage 2 — Python runtime
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
iputils-ping \
fping \
&& rm -rf /var/lib/apt/lists/*
COPY broadlinkmanager/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source
COPY broadlinkmanager/ /app/
# Copy built frontend from stage 1
COPY --from=frontend /app/dist /app/dist
# Runtime data directory
RUN mkdir -p /app/data
EXPOSE 7020
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:7020/api/version',timeout=3).status==200 else 1)"
CMD ["python", "broadlinkmanager.py"]
```
- [ ] **Step 3: Verify Docker build succeeds**
```bash
docker build -t broadlinkmanager-test . 2>&1 | tail -10
```
Expected: `Successfully built …`
- [ ] **Step 4: Commit**
```bash
git add Dockerfile
git commit -m "Update Dockerfile: multi-stage build (Node frontend + Python runtime)"
```
---
### Task 21: Remove Jinja2 template routes
**Files:**
- Modify: `broadlinkmanager/app/main.py`
The SPA handles all page routing now. The old Jinja2 routes in `broadlinkmanager.py` are gone (it's now a thin launcher). Verify no Jinja2 routes remain and that all old URLs redirect to the SPA.
- [ ] **Step 1: Verify old HTML routes now serve the React SPA**
```bash
cd broadlinkmanager && python broadlinkmanager.py &
sleep 2
for path in / /saved /generator /livolo /energenie /repeats /convert /about; do
status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:7020$path)
echo "$path → $status"
done
kill %1
cd ..
```
Expected: all paths return `200`.
- [ ] **Step 2: (Optional) Remove old templates if everything works**
```bash
# Only run this once the React UI is verified working
# git rm -r broadlinkmanager/templates/
# git commit -m "Remove legacy Jinja2 templates (replaced by React SPA)"
```
Leave templates in place for now; remove in a separate clean-up commit after user acceptance.
- [ ] **Step 3: Final build verification**
```bash
cd broadlinkmanager/web && npm run build && cd ..
python broadlinkmanager.py &
sleep 3
curl -s http://localhost:7020/api/codes
curl -s http://localhost:7020/api/version
curl -s "http://localhost:7020/device/ping?host=127.0.0.1"
kill %1
cd ..
```
Expected:
- `/api/codes` → `[]` or list of codes
- `/api/version` → `{"version":"6.0.0"}`
- `/device/ping` → `{"status":"online","success":true}`
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "Verify SPA routing; all 8 pages served via React Router"
```
---
## Self-Review
**Spec coverage check:**
- ✅ Backend split into `app/routers/` (devices, commands, codes) — Tasks 3–6
- ✅ Pydantic models for all responses — Task 1
- ✅ DB context manager — Task 2
- ✅ React + TypeScript + Vite + Tailwind scaffold — Task 7
- ✅ TanStack Query for server state — Tasks 8, 14–16
- ✅ React context for theme + panel state — Tasks 9, 10
- ✅ All 8 pages — Tasks 14–17
- ✅ Icon-only sidebar with hover expand — Task 12
- ✅ Mobile overlay drawer — Task 12
- ✅ Dark/light toggle persisted to localStorage — Task 9
- ✅ Sky Blue / Cyan color scheme — Tasks 11–17 (Tailwind classes throughout)
- ✅ Command Panel slide-over with IR/RF/Send tabs — Task 15
- ✅ RF step tracker with polling — Task 15
- ✅ Saved Codes CRUD, search, filter, pagination, CSV export — Task 16
- ✅ Code generators (RF433/315, Livolo, Energenie, Repeats, Convert) — Task 17
- ✅ `/api/version` endpoint — Task 6
- ✅ SPA catch-all fallback — Task 6
- ✅ Vite proxy in dev mode — Task 7
- ✅ Dockerfile multi-stage build — Task 20
- ✅ dist/.gitkeep placeholder — Task 18
- ✅ .gitignore updates — Task 19