"""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 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 # ----- 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.") since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None 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") # ----- 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