import json import os import subprocess import sys from microdot import Microdot from serial.tools import list_ports controller = Microdot() def _repo_root() -> str: return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) def _led_cli_path() -> str: return os.path.join(_repo_root(), "led-tool", "cli.py") def _build_led_cli_command(port: str, payload: dict): cmd = [sys.executable, _led_cli_path(), "--port", port] flag_map = ( ("name", "--name"), ("led_pin", "--pin"), ("num_leds", "--leds"), ("brightness", "--brightness"), ("transport", "--transport"), ("ssid", "--ssid"), ("password", "--wifi-password"), ("wifi_channel", "--wifi-channel"), ("default", "--default"), ) for key, flag in flag_map: value = payload.get(key) if value is None: continue value_str = str(value).strip() if value_str == "": continue cmd.extend([flag, value_str]) return cmd def _run_led_cli_command(cmd, cli_path: str, timeout_s=180): try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout_s, cwd=os.path.dirname(cli_path), ) except subprocess.TimeoutExpired: return ( json.dumps({"error": "led-tool command timed out after 180 seconds"}), 504, {"Content-Type": "application/json"}, ) except Exception as exc: return ( json.dumps({"error": str(exc)}), 500, {"Content-Type": "application/json"}, ) return ( json.dumps( { "ok": result.returncode == 0, "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr, "command": cmd, } ), 200, {"Content-Type": "application/json"}, ) def _extract_settings_from_stdout(stdout: str): text = (stdout or "").strip() if not text: return None try: parsed = json.loads(text) return parsed if isinstance(parsed, dict) else None except Exception: return None @controller.get("/ports") async def list_serial_ports(request): ports = [] for info in list_ports.comports(): ports.append( { "device": info.device, "description": info.description, "hwid": info.hwid, } ) return ( json.dumps( { "ports": ports, "led_cli_exists": os.path.exists(_led_cli_path()), } ), 200, {"Content-Type": "application/json"}, ) @controller.post("/settings") async def apply_settings(request): data = request.json or {} port = str(data.get("port") or "").strip() if not port: return ( json.dumps({"error": "port is required"}), 400, {"Content-Type": "application/json"}, ) cli_path = _led_cli_path() if not os.path.exists(cli_path): return ( json.dumps({"error": "led-tool/cli.py not found"}), 500, {"Content-Type": "application/json"}, ) cmd = _build_led_cli_command(port, data) + ["--follow"] return _run_led_cli_command(cmd, cli_path, timeout_s=None) @controller.post("/reset") @controller.post("/reset/") async def reset_device(request): data = request.json or {} port = str(data.get("port") or "").strip() if not port: return ( json.dumps({"error": "port is required"}), 400, {"Content-Type": "application/json"}, ) cli_path = _led_cli_path() if not os.path.exists(cli_path): return ( json.dumps({"error": "led-tool/cli.py not found"}), 500, {"Content-Type": "application/json"}, ) cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"] return _run_led_cli_command(cmd, cli_path, timeout_s=None) @controller.get("/settings") async def read_settings(request): port = str(request.args.get("port") or "").strip() if not port: return ( json.dumps({"error": "port is required"}), 400, {"Content-Type": "application/json"}, ) cli_path = _led_cli_path() if not os.path.exists(cli_path): return ( json.dumps({"error": "led-tool/cli.py not found"}), 500, {"Content-Type": "application/json"}, ) cmd = [sys.executable, cli_path, "--port", port, "--show"] body, status, headers = _run_led_cli_command(cmd, cli_path) if status != 200: return body, status, headers data = json.loads(body) data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "") return json.dumps(data), status, headers