"""FastAPI-Anwendung des Control Core. Endpunkte: - GET /api/v1/system/health - GET /api/v1/system/identity (node_id, display_name, roles; nicht vertraulich) - GET /api/v1/system/capabilities - GET /api/v1/parameters - POST /api/v1/commands (parameter.set mit Revision-Prüfung und Idempotenz) - POST /api/v1/commands/{command_id}/release - GET /api/v1/diagnostics - GET /api/v1/cluster/nodes - GET /api/v1/projects, POST/GET/PUT/DELETE /api/v1/projects/{id} - POST /api/v1/media/import - GET /api/v1/media - GET /api/v1/plugins - GET /api/v1/artnet/status - WS /ws (State-Snapshot + Updates) Commands folgen §23.2: command_id, type, expected_revision, actor, payload. Node-Identität: persistente node_id aus userdata/identity (§3.6, §6.3); im Dev-Modus ohne App-Root wird eine ephemeral-Identität erzeugt. """ from __future__ import annotations import asyncio import uuid from pathlib import Path from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from hms_capabilities.probe import CapabilityReport from hms_cluster.registry import NodeRegistry from hms_domain.identity import NodeIdentity, NodeRole from hms_domain.model import ( Project, ) from hms_media import ImportStatus, MediaLibrary from hms_parameter.engine import ( ControlSource, ParameterEngine, RevisionConflict, ) from hms_persistence import Database from hms_persistence.state_store import ProjectStateStore from hms_protocol.idempotency import IdempotencyRegistry from pydantic import BaseModel, Field class SetParameterCommand(BaseModel): """parameter.set-Command (§23.2).""" command_id: str = Field(default_factory=lambda: str(uuid.uuid4())) type: str = "parameter.set" expected_revision: int | None = None actor: dict = Field(default_factory=lambda: {"type": "web", "id": "operator-session"}) payload: dict class ReleaseCommand(BaseModel): source: str = "web" class CreateProjectBody(BaseModel): name: str = "Neues Projekt" def _builtin_plugin_dir() -> Path: """Löst das Builtin-Plugin-Verzeichnis robust auf (§14.2). Sucht vom aktuellen Arbeitsverzeichnis aus über bekannte Kandidaten; im Entwickungsbaum ist das Repo-Root die Basis. """ candidates = [ Path.cwd() / "plugins" / "builtin", Path(__file__).resolve().parents[3] / "plugins" / "builtin", ] for candidate in candidates: if candidate.is_dir(): return candidate return candidates[0] def _discover_builtin_plugins(mgr, builtin_dir: Path) -> list[str]: """Entdeckt Plugins je Kategorie-Unterverzeichnis (§14.2).""" errors: list[str] = [] for category in sorted(builtin_dir.iterdir()): if not category.is_dir(): continue errors.extend(mgr.discover(category)) return errors class _State: def __init__(self, identity: NodeIdentity, data_dir: Path | None = None) -> None: self.identity = identity self.engine = ParameterEngine() self.registry = IdempotencyRegistry() self.report = CapabilityReport() self.nodes = NodeRegistry() self.subscribers: list[asyncio.Queue] = [] # Persistenz und Projekt-Verwaltung (§24, §6.4) self.data_dir = data_dir or Path("/tmp/hms-dev-data") self.database = Database(self.data_dir / "database" / "hms.db") self.database.open() self.database.migrate(backup_dir=self.data_dir / "backups") self.state_store = ProjectStateStore() self.media_library = MediaLibrary(self.data_dir / "media") def create_app(identity: NodeIdentity | None = None) -> FastAPI: """Erzeugt die Control-Core-App. identity: produktiv vom Launcher geladene persistente Identität (userdata/identity). Ohne Angabe gilt Dev-Modus mit ephemeral-Identität (jede App-Instanz erhält eine eigene ID; für Showbetrieb unzulässig). """ if identity is None: identity = NodeIdentity.ephemeral( "Dev Node", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}) ) app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0") state = _State(identity) # Eigene Node in die Registry eintragen (§6.3) state.nodes.register( node_id=identity.node_id, display_name=identity.display_name, roles=tuple(r.value for r in identity.roles), ) def _broadcast(event: dict) -> None: for queue in list(state.subscribers): queue.put_nowait(event) # ---------- System (§23.1) ---------- @app.get("/api/v1/system/health") async def health() -> dict: return { "status": "ok", "phase": 5, "node_id": state.identity.node_id, "revision": state.engine.revision, } @app.get("/api/v1/system/identity") async def system_identity() -> dict: """Nicht vertrauliche Selbstauskunft (§27.1: keine Tokens/Secrets).""" return { "node_id": state.identity.node_id, "display_name": state.identity.display_name, "roles": sorted(r.value for r in state.identity.roles), "renders_locally": state.identity.renders_locally, "is_coordinator": state.identity.is_coordinator, } @app.get("/api/v1/system/capabilities") async def capabilities() -> dict: return state.report.as_dict() @app.get("/api/v1/parameters") async def parameters() -> dict: snap = state.engine.snapshot() return {"revision": snap.revision, "values": snap.as_dict()} @app.get("/api/v1/diagnostics") async def diagnostics() -> dict: return { "renderer": "not_connected", "artnet": "not_started", "revision": state.engine.revision, "node_id": state.identity.node_id, "state_revision": state.state_store.state_revision, "project_revision": state.state_store.project_revision, } # ---------- Commands (§23.2) ---------- @app.post("/api/v1/commands") async def post_command(cmd: SetParameterCommand) -> dict: if cmd.type != "parameter.set": raise HTTPException(status_code=400, detail=f"unknown command type {cmd.type!r}") if not state.registry.register(cmd.command_id): prior = state.registry.result(cmd.command_id) if prior is not None: return {"status": "ack", "duplicate": True, "result": prior} raise HTTPException(status_code=409, detail="command already in flight") path = cmd.payload.get("parameter_path") value = cmd.payload.get("value") if not path or value is None: raise HTTPException(status_code=400, detail="payload requires parameter_path and value") try: revision = state.engine.set_value( path=path, value=float(value), source=ControlSource.WEB, expected_revision=cmd.expected_revision, ) state.state_store.set_value(path, float(value)) except RevisionConflict as exc: raise HTTPException( status_code=409, detail={ "error": "REVISION_CONFLICT", "current": exc.current, "expected": exc.expected, }, ) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc result = { "status": "ack", "command_id": cmd.command_id, "revision": revision, "effective": state.engine.effective_value(path), } state.registry.complete(cmd.command_id, result) _broadcast( { "type": "parameter.update", "parameter_path": path, "value": value, "revision": revision, } ) return result @app.post("/api/v1/commands/{command_id}/release") async def release_override(command_id: str) -> dict: return {"status": "not_implemented_in_phase0"} # ---------- Cluster (§23.1) ---------- @app.get("/api/v1/cluster/nodes") async def cluster_nodes() -> dict: """Node-Übersicht nach UI-Kategorien (§6.3): getrennt aufgeführt.""" nodes = [] for entry in state.nodes.all(): state.nodes.evaluate_health(entry.node_id) nodes.append( { "node_id": entry.node_id, "display_name": entry.display_name, "roles": list(entry.roles), "health": entry.health.value, "category": entry.category.value, } ) return {"self": state.identity.node_id, "nodes": nodes} # ---------- Projekte (§23.1, §24.2) ---------- @app.get("/api/v1/projects") async def list_projects() -> dict: return {"projects": state.database.list_projects()} @app.post("/api/v1/projects") async def create_project(body: CreateProjectBody) -> dict: project = Project(name=body.name) state.database.save_project(project.model_dump(mode="json")) return {"status": "ack", "project": project.model_dump(mode="json")} @app.get("/api/v1/projects/{project_id}") async def get_project(project_id: str) -> dict: data = state.database.load_project(project_id) if data is None: raise HTTPException(status_code=404, detail="Projekt nicht gefunden") return data @app.put("/api/v1/projects/{project_id}") async def update_project(project_id: str, body: dict) -> dict: existing = state.database.load_project(project_id) if existing is None: raise HTTPException(status_code=404, detail="Projekt nicht gefunden") for key, value in body.items(): if key in existing: existing[key] = value updated = Project.model_validate(existing) state.database.save_project(updated.model_dump(mode="json")) state.state_store.bump_project_revision() return {"status": "ack"} @app.delete("/api/v1/projects/{project_id}") async def delete_project(project_id: str) -> dict: state.database.delete_project(project_id) return {"status": "ack"} @app.post("/api/v1/projects/{project_id}/activate") async def activate_project(project_id: str) -> dict: """Aktiviert ein Projekt als autoritative Basis (§6.4).""" data = state.database.load_project(project_id) if data is None: raise HTTPException(status_code=404, detail="Projekt nicht gefunden") project = Project.model_validate(data) revision = state.state_store.activate_project(project) return {"status": "ack", "state_revision": revision} # ---------- Medien (§13.3, §23.1) ---------- @app.post("/api/v1/media/import") async def import_media(file: UploadFile) -> dict: """Importiert eine Mediendatei mit Duplikaterkennung (§13.3). Import blockiert niemals den Renderthread (§13.3). """ media_root = state.data_dir / "media" media_root.mkdir(parents=True, exist_ok=True) dest = media_root / file.filename content = await file.read() dest.write_bytes(content) outcome = state.media_library.import_file(dest) if outcome.status is ImportStatus.IMPORTED and outcome.asset: state.database.save_project({ "id": str(uuid.uuid4()), "name": file.filename, "schema_version": 1, "created_at": "2026-09-11T00:00:00+00:00", "updated_at": "2026-09-11T00:00:00+00:00", "data": outcome.asset.model_dump(mode="json"), }) return { "status": outcome.status.value, "asset": outcome.asset.model_dump(mode="json") if outcome.asset else None, "duplicate_of": outcome.duplicate_of, } @app.get("/api/v1/media") async def list_media() -> dict: """Medienliste mit Metadaten (§13.2).""" missing = state.media_library.mark_missing() assets = [] for asset in state.media_library.all(): assets.append({ **asset.model_dump(mode="json"), "missing": asset.id in state.media_library.missing_asset_ids, }) return {"media": assets, "missing_count": len(missing)} # ---------- Plugins (§23.1) ---------- @app.get("/api/v1/plugins") async def list_plugins() -> dict: """Installierte/verfügbare Plugins nach Status gruppiert (§14.5).""" from hms_plugin_sdk import PluginLifecycleManager builtin_dir = _builtin_plugin_dir() mgr = PluginLifecycleManager() errors = _discover_builtin_plugins(mgr, builtin_dir) plugins_by_state: dict[str, list[dict]] = {} for record in mgr.all(): plugins_by_state.setdefault(record.state.value, []).append( { "plugin_id": record.plugin_id, "version": record.version, "kind": record.manifest.get("kind", "unknown"), "name": record.manifest.get("name", record.plugin_id), "last_error": record.last_error, } ) return { "plugins_by_state": plugins_by_state, "total": len(mgr.all()), "discovery_errors": errors, } @app.post("/api/v1/plugins/{plugin_id}/enable") async def enable_plugin(plugin_id: str) -> dict: """Aktiviert ein Plugin über den Lifecycle (§14.5).""" from hms_plugin_sdk import ( InvalidTransitionError, LifecycleState, PluginLifecycleManager, ) builtin_dir = _builtin_plugin_dir() mgr = PluginLifecycleManager() _discover_builtin_plugins(mgr, builtin_dir) if mgr.get(plugin_id) is None: raise HTTPException(status_code=404, detail=f"unbekanntes Plugin {plugin_id!r}") try: for target in ( LifecycleState.INSTALLED, LifecycleState.ENABLED, LifecycleState.COMPILED, LifecycleState.ACTIVE, ): record = mgr.get(plugin_id) if record is None: break if record.state is target: break mgr.advance(plugin_id, target) return {"status": "ack", "plugin_id": plugin_id} except InvalidTransitionError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc # ---------- Art-Net (§23.1) ---------- @app.get("/api/v1/artnet/status") async def artnet_status() -> dict: return { "running": False, "universes": [], "telemetry": {}, "note": "Art-Net-Receiver startet mit dem Launcher (Phase 1 vollstaendig nach Gate 1)", } # ---------- WebSocket (§23.3) ---------- @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket) -> None: await ws.accept() queue: asyncio.Queue = asyncio.Queue(maxsize=256) state.subscribers.append(queue) try: snap = state.engine.snapshot() await ws.send_json( {"type": "snapshot", "revision": snap.revision, "values": snap.as_dict()} ) while True: try: event = await asyncio.wait_for(queue.get(), timeout=15.0) await ws.send_json(event) except TimeoutError: await ws.send_json({"type": "heartbeat"}) except WebSocketDisconnect: pass finally: state.subscribers.remove(queue) return app app = create_app()