#!/usr/bin/env python3 """Start N7Web, the read-only OTA endpoint, and the LAN mode-card service.""" from __future__ import annotations import argparse import json import os import shlex import shutil import socket import subprocess import sys import time from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from urllib import error as urlerror from urllib import request as urlrequest REPOSITORY_ROOT = Path(__file__).resolve().parents[1] DEFAULT_CATALOG_PATH = REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json" PYTHON_SOURCE_PATHS = ( REPOSITORY_ROOT / "src", REPOSITORY_ROOT / "domains" / "seat" / "src", ) WEB_CONSOLE_ROOT = REPOSITORY_ROOT / "apps" / "web-console" @dataclass(frozen=True) class ChildProcess: name: str process: subprocess.Popen[bytes] def _port(value: str) -> int: try: port = int(value) except ValueError as exc: raise argparse.ArgumentTypeError("port must be an integer") from exc if not 1 <= port <= 65_535: raise argparse.ArgumentTypeError("port must be between 1 and 65535") return port def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Start the N7Web application and the read-only LAN mode-card service. " "Mode-card synchronization is event-driven inside the main service." ) ) parser.add_argument( "--host", default="127.0.0.1", help="main N7Web bind address (defaults to local management only)", ) parser.add_argument("--port", type=_port, default=8000, help="main N7Web port") parser.add_argument( "--ota-host", default="127.0.0.1", help="read-only firmware OTA bind address (designed for an HTTPS tunnel)", ) parser.add_argument( "--ota-port", type=_port, default=8012, help="read-only firmware OTA port", ) parser.add_argument( "--scene-bind", default="0.0.0.0", help="read-only mode-card HTTP/UDP bind address", ) parser.add_argument( "--scene-port", type=_port, default=8090, help="read-only mode-card HTTP/UDP port", ) parser.add_argument( "--catalog", type=Path, default=DEFAULT_CATALOG_PATH, help="synchronized n7-scenes JSON path", ) parser.add_argument( "--server-id", default="n7-cognitive-agent", help="server_id written to and served with the catalog", ) parser.add_argument( "--python", type=Path, help="child Python executable (defaults to the repository .venv)", ) parser.add_argument( "--catalog-ready-timeout", type=float, default=30.0, help="seconds to wait for the main service to create a valid catalog", ) parser.add_argument( "--dry-run", action="store_true", help="print the resolved commands without starting processes", ) parser.add_argument( "--skip-web-build", action="store_true", help="reuse the existing Vite dist output instead of rebuilding it", ) return parser def _resolve_path(path: Path) -> Path: expanded = path.expanduser() if not expanded.is_absolute(): expanded = REPOSITORY_ROOT / expanded return expanded.resolve() def _resolve_python(configured: Path | None) -> Path: if configured is not None: executable = _resolve_path(configured) if not executable.is_file(): raise ValueError(f"Python executable does not exist: {executable}") return executable candidates = ( REPOSITORY_ROOT / ".venv" / "Scripts" / "python.exe", REPOSITORY_ROOT / ".venv" / "bin" / "python", ) for candidate in candidates: if candidate.is_file(): return candidate.resolve() return Path(sys.executable).resolve() def _child_environment(base: Mapping[str, str] | None = None) -> dict[str, str]: environment = dict(os.environ if base is None else base) existing = [ entry for entry in environment.get("PYTHONPATH", "").split(os.pathsep) if entry ] combined = [str(path.resolve()) for path in PYTHON_SOURCE_PATHS] seen = {os.path.normcase(os.path.abspath(entry)) for entry in combined} for entry in existing: normalized = os.path.normcase(os.path.abspath(entry)) if normalized not in seen: combined.append(entry) seen.add(normalized) environment["PYTHONPATH"] = os.pathsep.join(combined) environment.setdefault("PYTHONUNBUFFERED", "1") return environment def _commands( args: argparse.Namespace, python: Path, catalog: Path, ) -> tuple[list[str], list[str], list[str]]: main_command = [ str(python), "-m", "cognitive_agent.server", "--host", args.host, "--port", str(args.port), "--scene-catalog", str(catalog), "--scene-server-id", args.server_id, ] scene_command = [ str(python), "-m", "cognitive_agent.lan_scene_server", "--bind", args.scene_bind, "--port", str(args.scene_port), "--catalog", str(catalog), "--server-id", args.server_id, ] ota_command = [ str(python), "-m", "cognitive_agent.firmware_ota_server", "--host", args.ota_host, "--port", str(args.ota_port), ] return main_command, scene_command, ota_command def _display_command(command: Sequence[str]) -> str: if os.name == "nt": return subprocess.list2cmdline(command) return shlex.join(command) def _probe_host(bind_address: str) -> str: address = str(bind_address).strip() if address in {"", "0.0.0.0"}: return "127.0.0.1" if address == "::": return "::1" return address def _tcp_port_is_listening(bind_address: str, port: int) -> bool: try: with socket.create_connection((_probe_host(bind_address), port), timeout=0.25): return True except OSError: return False def _ensure_service_ports_available(args: argparse.Namespace) -> None: endpoints = ( ("main N7Web service", args.host, args.port), ("LAN mode-card service", args.scene_bind, args.scene_port), ) occupied = [ f"{name} ({_probe_host(host)}:{port})" for name, host, port in endpoints if _tcp_port_is_listening(host, port) ] if occupied: joined = ", ".join(occupied) raise RuntimeError( f"service port already in use: {joined}; stop the previous N7Web " "instance before starting another" ) def _resolve_npm() -> Path: executable = shutil.which("npm.cmd" if os.name == "nt" else "npm") if not executable: raise ValueError( "npm is required to build the Vue 3 + Vite web console; " "install Node.js or pass --skip-web-build to reuse an existing dist" ) return Path(executable).resolve() def _web_build_commands(npm: Path) -> list[list[str]]: commands: list[list[str]] = [] vite_binary = WEB_CONSOLE_ROOT / "node_modules" / ".bin" / ( "vite.cmd" if os.name == "nt" else "vite" ) if not vite_binary.is_file(): commands.append([str(npm), "ci"]) commands.append([str(npm), "run", "build"]) return commands def _build_web_console(npm: Path) -> None: for command in _web_build_commands(npm): print(f"Web build: {_display_command(command)}", flush=True) completed = subprocess.run(command, cwd=WEB_CONSOLE_ROOT, check=False) if completed.returncode != 0: raise RuntimeError( f"Vue 3 + Vite web build failed with exit code {completed.returncode}" ) def _catalog_is_ready(path: Path) -> bool: try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return False return bool( isinstance(document, dict) and document.get("service") == "n7-scenes" and document.get("schema_version") == 1 and isinstance(document.get("scenes"), list) ) def _ota_service_is_ready(host: str, port: int) -> bool: probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host if ":" in probe_host and not probe_host.startswith("["): probe_host = f"[{probe_host}]" request = urlrequest.Request( f"http://{probe_host}:{port}/healthz", headers={"Accept": "application/json"}, ) opener = urlrequest.build_opener(urlrequest.ProxyHandler({})) try: with opener.open(request, timeout=1.0) as response: document = json.loads(response.read().decode("utf-8")) except (OSError, ValueError, urlerror.URLError): return False return bool( response.status == 200 and isinstance(document, dict) and document.get("status") == "ok" and document.get("service") == "n7-firmware-ota" ) def _wait_for_catalog( path: Path, main_process: subprocess.Popen[bytes], timeout: float, ) -> None: if timeout <= 0: raise ValueError("catalog-ready-timeout must be greater than zero") deadline = time.monotonic() + timeout while not _catalog_is_ready(path): exit_code = main_process.poll() if exit_code is not None: raise RuntimeError( f"main N7Web service exited before the catalog was ready " f"(exit code {exit_code})" ) if time.monotonic() >= deadline: raise RuntimeError( f"timed out waiting for the synchronized catalog: {path}" ) time.sleep(0.1) def _wait_for_exit(children: Sequence[ChildProcess]) -> tuple[str, int]: while True: for child in children: exit_code = child.process.poll() if exit_code is not None: return child.name, exit_code time.sleep(0.25) def _stop_children( children: Sequence[ChildProcess], *, interrupt_was_broadcast: bool, ) -> None: # Console Ctrl+C is delivered to the parent and both children. Give the # services time to run their own finally blocks before escalating. if interrupt_was_broadcast: deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if all(child.process.poll() is not None for child in children): return time.sleep(0.1) running = [child for child in children if child.process.poll() is None] for child in running: child.process.terminate() deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if all(child.process.poll() is not None for child in running): return time.sleep(0.1) for child in running: if child.process.poll() is None: child.process.kill() def run(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) if not args.server_id.strip(): print("error: server-id must not be empty", file=sys.stderr) return 2 try: python = _resolve_python(args.python) catalog = _resolve_path(args.catalog) npm = None if args.skip_web_build else _resolve_npm() if args.catalog_ready_timeout <= 0: raise ValueError("catalog-ready-timeout must be greater than zero") except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 2 environment = _child_environment() main_command, scene_command, ota_command = _commands(args, python, catalog) print(f"Repository: {REPOSITORY_ROOT}", flush=True) print(f"Catalog: {catalog}", flush=True) print(f"Main: {_display_command(main_command)}", flush=True) print(f"OTA: {_display_command(ota_command)}", flush=True) print(f"Cards: {_display_command(scene_command)}", flush=True) if npm is not None: for command in _web_build_commands(npm): print(f"Web build: {_display_command(command)}", flush=True) if args.dry_run: return 0 try: _ensure_service_ports_available(args) if npm is not None: _build_web_console(npm) except RuntimeError as exc: print(f"startup failed: {exc}", file=sys.stderr, flush=True) return 1 children: list[ChildProcess] = [] interrupted = False try: main_process = subprocess.Popen( main_command, cwd=REPOSITORY_ROOT, env=environment, ) children.append(ChildProcess("main N7Web service", main_process)) _wait_for_catalog(catalog, main_process, args.catalog_ready_timeout) if _ota_service_is_ready(args.ota_host, args.ota_port): print( f"OTA service already available at " f"http://{args.ota_host}:{args.ota_port}; reusing it.", flush=True, ) else: ota_process = subprocess.Popen( ota_command, cwd=REPOSITORY_ROOT, env=environment, ) children.append(ChildProcess("firmware OTA service", ota_process)) scene_process = subprocess.Popen( scene_command, cwd=REPOSITORY_ROOT, env=environment, ) children.append(ChildProcess("LAN mode-card service", scene_process)) print( "All services are running; press Ctrl+C once to stop all of them.", flush=True, ) name, exit_code = _wait_for_exit(children) print(f"{name} exited (exit code {exit_code}); stopping the other service.") return exit_code if exit_code != 0 else 1 except KeyboardInterrupt: interrupted = True print("\nStopping N7Web services...", flush=True) return 0 except (OSError, RuntimeError, ValueError) as exc: print(f"startup failed: {exc}", file=sys.stderr, flush=True) return 1 finally: _stop_children(children, interrupt_was_broadcast=interrupted) if __name__ == "__main__": raise SystemExit(run())