"""API FastAPI del Runtime server. - WebSocket: `/ws/realtime` — pub/sub de tag updates + alarm events - REST: `/tags`, `/tags/{id}/history`, `/alarms`, `/health`, `/project` """ from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager, suppress from datetime import datetime from typing import Any from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, Response from vmssailor.runtime.server.runtime_app import RuntimeApp from vmssailor.version import __version__ logger = logging.getLogger(__name__) def create_app(runtime: RuntimeApp) -> FastAPI: """Construye la FastAPI app y monta endpoints.""" @asynccontextmanager async def lifespan(_app: FastAPI): await runtime.start() try: yield finally: await runtime.stop() app = FastAPI( title="VMS-Sailor Runtime API", version=__version__, description="On-board Runtime API: tags, history, alarms, control.", lifespan=lifespan, ) app.state.runtime = runtime # ----- Root + favicon (UI amigable de bienvenida) ----------------- @app.get("/", response_class=HTMLResponse, include_in_schema=False) def root() -> str: return _RENDER_ROOT_HTML(runtime) @app.get("/favicon.ico", include_in_schema=False) def favicon() -> Response: # SVG inline para evitar dependencias en disco svg = _FAVICON_SVG.encode("utf-8") return Response(content=svg, media_type="image/svg+xml") # ----- Health ------------------------------------------------------ @app.get("/health") def health() -> dict[str, Any]: return { "status": "ok", "vmssailor_version": __version__, "tag_store": runtime.tag_store.stats(), "historian": runtime.historian.stats(), "active_alarms": len(runtime.alarm_engine.active_alarms()), "project": { "id": runtime.project.id, "name": runtime.project.name, "vessel": runtime.project.vessel.name, }, } # ----- Project ---------------------------------------------------- @app.get("/project") def project() -> dict[str, Any]: p = runtime.project return { "id": p.id, "name": p.name, "customer": p.customer, "vessel": { "id": p.vessel.id, "name": p.vessel.name, "type": p.vessel.type.value, "loa_m": p.vessel.length_overall_m, "beam_m": p.vessel.beam_max_m, "draft_m": p.vessel.draft_m, }, "systems_enabled": [s.value for s in p.systems_enabled], "stats": p.stats(), } # ----- Tags -------------------------------------------------------- @app.get("/tags") def list_tags() -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for tag_id, tag in runtime.tag_store.all_tags().items(): tv = runtime.tag_store.get(tag_id) out.append( { "id": tag_id, "description": tag.description, "unit_si": tag.unit_si.value, "controllable": tag.controllable, "value": tv.value if tv else None, "quality": tv.quality.value if tv else None, "timestamp": tv.timestamp.isoformat() if tv else None, "range_normal_min": tag.range_normal_min, "range_normal_max": tag.range_normal_max, } ) return out @app.get("/tags/{tag_id}") def get_tag(tag_id: str) -> dict[str, Any]: tag = runtime.tag_store.get_tag(tag_id) tv = runtime.tag_store.get(tag_id) if tag is None or tv is None: raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.") return { "id": tag_id, "description": tag.description, "unit_si": tag.unit_si.value, "value": tv.value, "quality": tv.quality.value, "timestamp": tv.timestamp.isoformat(), "alarms": [a.model_dump(mode="json") for a in tag.alarms], } @app.get("/tags/{tag_id}/history") def history( tag_id: str, since: str | None = None, until: str | None = None, limit: int = 1000, ) -> list[dict[str, Any]]: if tag_id not in runtime.tag_store: raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.") try: since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None except ValueError: raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).") return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit) # ----- Alarms ------------------------------------------------------ @app.get("/alarms") def alarms(state: str | None = None) -> list[dict[str, Any]]: active = runtime.alarm_engine.active_alarms() if state == "active": return [a.model_dump(mode="json") for a in active] all_events = runtime.alarm_events return [a.model_dump(mode="json") for a in all_events] @app.post("/alarms/{alarm_id}/ack") def ack_alarm(alarm_id: str, user: str = "anonymous") -> dict[str, Any]: acked = runtime.alarm_engine.ack(alarm_id, user=user) if acked is None: raise HTTPException(status_code=404, detail=f"Alarma '{alarm_id}' no activa.") return acked.model_dump(mode="json") # ----- Log Book ---------------------------------------------------- @app.get("/logbook") def logbook_entries( kind: str | None = None, since: str | None = None, until: str | None = None, limit: int = 200, ) -> list[dict[str, Any]]: from vmssailor.runtime.server.logbook import LogEntryKind try: kind_enum = LogEntryKind(kind) if kind else None except ValueError: raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}") try: since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None except ValueError: raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).") entries = runtime.logbook.query( kind=kind_enum, since=since_dt, until=until_dt, limit=limit ) return [ { "id": e.id, "kind": e.kind.value, "timestamp": e.timestamp.isoformat(), "summary": e.summary, "payload": e.payload, "user": e.user, "hash": e.hash[:16] + "…", } for e in entries ] @app.post("/logbook") def logbook_append_manual( kind: str = "manual", summary: str = "", user: str = "operator", ) -> dict[str, Any]: import asyncio from vmssailor.runtime.server.logbook import LogEntryKind try: kind_enum = LogEntryKind(kind) except ValueError as err: raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}") from err if not summary: raise HTTPException(status_code=400, detail="summary requerido") async def _do_append(): return await runtime.logbook.append(kind_enum, summary, user=user) entry = asyncio.run_coroutine_threadsafe(_do_append(), asyncio.get_event_loop()).result(timeout=2) return { "id": entry.id, "kind": entry.kind.value, "timestamp": entry.timestamp.isoformat(), "hash": entry.hash, } @app.get("/logbook/verify") def logbook_verify() -> dict[str, Any]: ok, broken = runtime.logbook.verify_chain() return {"ok": ok, "broken_entry_ids": broken} # ----- WebSocket --------------------------------------------------- @app.websocket("/ws/realtime") async def ws_realtime(ws: WebSocket) -> None: await ws.accept() q = runtime.tag_store.subscribe(maxsize=512) # Push current snapshot first for tv in runtime.tag_store.all_values().values(): await ws.send_json(tv.to_event_dict()) try: while True: try: tv = await asyncio.wait_for(q.get(), timeout=30.0) await ws.send_json(tv.to_event_dict()) except TimeoutError: await ws.send_json({"type": "heartbeat"}) except WebSocketDisconnect: pass finally: runtime.tag_store.unsubscribe(q) with suppress(Exception): await ws.close() return app # =========================================================================== # Root HTML (UI amigable de bienvenida con dashboard vivo) # =========================================================================== _FAVICON_SVG = """ """ def _RENDER_ROOT_HTML(runtime: RuntimeApp) -> str: project = runtime.project tag_stats = runtime.tag_store.stats() active_alarms = len(runtime.alarm_engine.active_alarms()) return f"""