df56f52091
vmssailor/runtime/server/nmea2000.py
- PgnFrame dataclass + parsers/encoders para PGNs clave:
- 127257 Attitude (yaw/pitch/roll en radianes)
- 127488 Engine Parameters Rapid (RPM, boost, trim)
- 127489 Engine Dynamic (oil, coolant, alternator, hours)
- 127505 Fluid Level (tanks)
- 127508 Battery Status
- 129025 Position Rapid
- N2KPublisher: tag updates -> PGNs publicados via transport callable
- Sprint 5: callable de testing/observabilidad
- Sprint 12+: integracion real con python-can / Actisense / YDWG-02
- N2KSubscriber: inyecta PGNs entrantes y los traduce a tag updates
- PGN_ATTITUDE -> VESSEL.ROLL_DEG / VESSEL.PITCH_DEG (PGN 127257 = AR-ECDIS source)
- PGN_ENGINE_RAPID -> ME_INST_{N}.RPM
vmssailor/runtime/server/logbook.py
- LogBook con cadena SHA-256 inmutable (prev_hash -> hash chain)
- LogEntryKind: ENGINE_START, ENGINE_STOP, ALARM_ACTIVE/ACK/CLEARED,
AUTHORITY_TRANSFER, OVERRIDE, SNAPSHOT, MANUAL
- verify_chain() detecta tampering (bit flip o reescritura)
- query() con filtros por kind/since/until/limit
- EngineLogWriter: auto-detecta start/stop via cruces de umbral RPM
con persistencia configurable (default 5s)
- SnapshotLogWriter: snapshots periodicos (default 15 min) condicional
a motor corriendo
API actualizada:
- GET /logbook con filtros
- POST /logbook (entrada manual)
- GET /logbook/verify (integridad de cadena)
RuntimeApp integra todos los servicios. El alarm engine ahora
auto-anota al log book via callback con strong-ref task tracking.
Tests (tests/runtime/, 10 nuevos, total 152/152):
- test_logbook: append, chain, tamper detection, engine start/stop,
snapshot writer con/sin motor corriendo
- test_nmea2000: PGN encode/decode roundtrip, publisher emits engine
rapid frames, subscriber handles attitude
152/152 pytest verde, ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
235 lines
8.1 KiB
Python
235 lines
8.1 KiB
Python
"""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")
|
|
|
|
# ----- 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
|
|
|
|
kind_enum = LogEntryKind(kind) if kind else None
|
|
since_dt = datetime.fromisoformat(since) if since else None
|
|
until_dt = datetime.fromisoformat(until) if until else None
|
|
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
|