sprint-4: Runtime server base — tag_store + historian + alarm engine + API FastAPI

Arquitectura asincronica completa para correr 24/7 a bordo del buque.

vmssailor/runtime/server/tag_store.py
- TagStore in-memory con pub/sub asyncio.Queue
- register_tag/register_many con valores iniciales
- TagValue dataclass: value + quality + timestamp + raw_value
- subscribe()/unsubscribe() para fan-out
- stats() con breakdown por Quality

vmssailor/runtime/server/historian.py
- Historian DuckDB embebido (in-memory o archivo)
- Reader loop suscrito al tag_store + buffer + flush periodico (1s)
- query(tag_id, since, until, limit) para series temporales
- Soporta valores numericos y boolean separadamente

vmssailor/runtime/server/alarm_engine.py
- Suscriptor al tag_store que evalua AlarmConfig por update
- Operators: >, >=, <, <=, ==, !=
- Hysteresis correcta: aplica al SALIR de alarma, no a entrar
- Delay configurable (persistencia minima antes de disparar)
- Estados: ACTIVE -> ACK (con user) -> CLEARED
- ack(alarm_id, user) reconoce sin clear

vmssailor/runtime/server/drivers.py
- SimulatorDriver: produce valores sinteticos creibles por UnitSI
- Tick configurable (default 0.5s)
- Respeta range_normal_min/max del tag para mantenerse en rango
- Permite probar UI/API sin hardware ni Modbus real

vmssailor/runtime/server/runtime_app.py
- RuntimeApp dataclass ensambla todos los servicios
- build_runtime(project) construye listo para correr
- start()/stop() async lifecycle ordenado

vmssailor/runtime/server/api.py
- FastAPI app con lifespan que arranca/detiene el runtime
- GET /health, /project
- GET /tags, /tags/{id}, /tags/{id}/history
- GET /alarms, POST /alarms/{id}/ack
- WebSocket /ws/realtime con snapshot inicial + push + heartbeat

runtime_server_main.py
- Entry point con argparse: --vmsproj, --host, --port, --db
- Sin --vmsproj usa proyecto demo Sprint 0 (genera simulator vivo)
- Lanza con uvicorn

Tests (tests/runtime/, 16 nuevos, total 142/142):
- test_tag_store: register, update, subscribe, unsubscribe, stats
- test_historian: roundtrip query, stats
- test_alarm_engine: fire when below, hysteresis clears, ack
- test_api: health, project, tags listing, history, alarms via httpx.ASGITransport

Para correr el servidor en vivo:
    uv run python runtime_server_main.py --verbose
Luego en otro shell:
    curl http://127.0.0.1:8765/health
    curl http://127.0.0.1:8765/tags | jq .

Dependencias agregadas:
- fastapi >=0.110
- uvicorn[standard] >=0.27
- websockets >=12.0
- duckdb >=0.10
- pymodbus >=3.5 (Sprint 5)
- python-can >=4.3 (Sprint 5)
- httpx >=0.27 (testing + cliente HTTP)

142/142 pytest verde, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:03:19 -04:00
parent fbce1ecb42
commit 36dda85259
16 changed files with 1579 additions and 9 deletions
+171
View File
@@ -0,0 +1,171 @@
"""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