# 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 `` - [ ] **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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const res = await fetch('/rf/status'); if (!res.ok) throw new Error('RF status failed'); return res.json(); } export async function continueRf(): Promise { 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 { 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 { 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 { 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 { 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 { 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({ 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(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 ( {children} ); } 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({ isOpen: false, device: null, activeTab: 'ir', prefilledCode: '', openPanel: () => {}, closePanel: () => {}, setTab: () => {}, }); export function PanelProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ 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 ( {children} ); } 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 ( {dot && } {children} ); } ``` - [ ] **Step 2: Create `web/src/components/ui/Button.tsx`** ```tsx import { type ButtonHTMLAttributes } from 'react'; interface ButtonProps extends ButtonHTMLAttributes { 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 ( ); } ``` - [ ] **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 (
{message}
); } ``` - [ ] **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 (
{toast.type === 'success' ? : } {toast.message}
); } // Global toast state — simple singleton pattern for this app's scale let _setToasts: React.Dispatch> | 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([]); _setToasts = setToasts; const dismiss = (id: number) => setToasts(prev => prev.filter(t => t.id !== id)); return (
{toasts.map(t => )}
); } ``` - [ ] **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 */}
B
{/* Nav items */} {/* Theme toggle */}
); return ( <> {/* Desktop sidebar */} {/* Mobile overlay */} {mobileOpen && (
)} ); } ``` - [ ] **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 (

{title}

{subtitle &&

{subtitle}

}
{actions &&
{actions}
}
); } ``` - [ ] **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 (
setMobileOpen(false)} />
setMobileOpen(true) }} />
); } ``` - [ ] **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 ( }> } /> } /> } /> } /> } /> } /> } /> } /> ); } ``` - [ ] **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( , ); ``` - [ ] **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
RF Generator — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/LivoloPage.tsx << 'EOF' export function LivoloPage() { return
Livolo — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/EnergenieePage.tsx << 'EOF' export function EnergenieePage() { return
Energenie — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/RepeatsPage.tsx << 'EOF' export function RepeatsPage() { return
Repeats — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/ConvertPage.tsx << 'EOF' export function ConvertPage() { return
Convert — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/AboutPage.tsx << 'EOF' export function AboutPage() { return
About — coming in Task 17
; } EOF cat > broadlinkmanager/web/src/pages/SavedCodesPage.tsx << 'EOF' export function SavedCodesPage() { return
Saved Codes — coming in Task 16
; } EOF cat > broadlinkmanager/web/src/pages/DevicesPage.tsx << 'EOF' export function DevicesPage() { return
Devices — coming in Task 14
; } 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 (
{[ { 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 => (
{s.label}
{s.value}
))}
); } 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 ( openPanel(device, 'ir')} >
{device.name}
Universal Remote
{device.type} {device.ip} {device.mac} {pingData === undefined ? ( Checking… ) : isOnline ? ( Online ) : ( Offline )}
e.stopPropagation()}>
); } 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 (
} />
{isFetching && (
Scanning network for Broadlink devices…
)}
Discovered Devices {devices.length} device{devices.length !== 1 ? 's' : ''}
{['Device', 'Type', 'IP Address', 'MAC Address', 'Status', 'Actions'].map(h => ( ))} {devices.length === 0 && !isFetching ? ( ) : ( devices.map((d: Device) => ) )}
{h}
No devices found. Click Rescan to search your network.
{isOpen && }
); } ``` - [ ] **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 (
{code && (
Save as: 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" />
)}
{code && ( )}
); } ``` - [ ] **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(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 (
{STEPS.map((s, i) => { const done = step > i + 1; const active = step === i + 1; return (
{done ? '✓' : i + 1}
{s.label}
{s.desc}
); })}
{step === 3 && ( <>
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" />
)}
{step === 0 && ( )} {step === 2 && ( )} {step > 0 && ( )}
); } ``` - [ ] **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 (
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" />
or pick from saved
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" />
{filtered.length === 0 ? (
No saved codes yet.
) : filtered.map((c: Code) => (
{c.CodeName} {c.CodeType}
))}
{lastResult && ( )}
); } ``` - [ ] **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 */}
{/* Panel */}
{/* Header */}
📡
{device.name}
{device.ip} · {device.mac}
{/* Tabs */}
{TABS.map(t => ( ))}
{/* Content */}
{activeTab === 'ir' && } {activeTab === 'rf' && } {activeTab === 'send' && }
); } ``` - [ ] **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 (
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" />