sprint-5: NMEA 2000 stub + Log Book naval básico

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>
This commit is contained in:
2026-05-17 20:30:41 -04:00
parent 36dda85259
commit df56f52091
6 changed files with 1007 additions and 4 deletions
+69 -4
View File
@@ -1,6 +1,7 @@
"""Orquestador del Runtime server: ensambla tag_store + historian + alarm engine + drivers.
"""Orquestador del Runtime server: ensambla servicios.
Sprint 4: simulator-only. Sprint 5: Modbus + NMEA 2000 reales.
Sprint 4: tag_store + historian + alarm engine + simulator + API.
Sprint 5: + log book (engine_log_writer + snapshot_writer) + nmea2000 stub.
"""
from __future__ import annotations
@@ -10,10 +11,18 @@ from dataclasses import dataclass, field
from pathlib import Path
from vmssailor.core.alarm import Alarm
from vmssailor.core.enums import AlarmState
from vmssailor.core.project import Project
from vmssailor.runtime.server.alarm_engine import AlarmEngine
from vmssailor.runtime.server.drivers import SimulatorDriver
from vmssailor.runtime.server.historian import Historian
from vmssailor.runtime.server.logbook import (
EngineLogWriter,
LogBook,
LogEntryKind,
SnapshotLogWriter,
)
from vmssailor.runtime.server.nmea2000 import N2KPublisher, N2KSubscriber
from vmssailor.runtime.server.tag_store import TagStore
logger = logging.getLogger(__name__)
@@ -21,25 +30,39 @@ logger = logging.getLogger(__name__)
@dataclass(slots=True)
class RuntimeApp:
"""Conjunto de servicios del Runtime. Se construye con `build_runtime()`."""
"""Conjunto de servicios del Runtime."""
project: Project
tag_store: TagStore
historian: Historian
alarm_engine: AlarmEngine
simulator: SimulatorDriver
logbook: LogBook
engine_log_writer: EngineLogWriter
snapshot_writer: SnapshotLogWriter
n2k_publisher: N2KPublisher
n2k_subscriber: N2KSubscriber
alarm_events: list[Alarm] = field(default_factory=list)
async def start(self) -> None:
await self.historian.start(self.tag_store)
await self.alarm_engine.start()
await self.simulator.start()
logger.info("RuntimeApp started — %d tags, simulator @ 0.5s", len(self.tag_store))
await self.engine_log_writer.start()
await self.snapshot_writer.start()
await self.n2k_publisher.start()
await self.n2k_subscriber.start()
logger.info("RuntimeApp started — %d tags", len(self.tag_store))
async def stop(self) -> None:
await self.n2k_subscriber.stop()
await self.n2k_publisher.stop()
await self.snapshot_writer.stop()
await self.engine_log_writer.stop()
await self.simulator.stop()
await self.alarm_engine.stop()
await self.historian.stop()
self.logbook.close()
logger.info("RuntimeApp stopped")
@@ -47,21 +70,58 @@ def build_runtime(
project: Project,
*,
historian_db: Path | str | None = None,
logbook_db: Path | str | None = None,
simulator_tick_s: float = 0.5,
snapshot_period_s: float = 900.0,
) -> RuntimeApp:
"""Construye un Runtime listo para usar (no arrancado todavía)."""
tag_store = TagStore()
tag_store.register_many(project.tags)
historian = Historian(historian_db)
logbook = LogBook(logbook_db)
alarm_events: list[Alarm] = []
# Strong refs para tareas async que persisten más allá del callback
_alarm_log_tasks: set = set()
def _on_alarm_event(alarm: Alarm) -> None:
alarm_events.append(alarm)
# Auto-log al log book (fire and forget)
import asyncio
kind_map = {
AlarmState.ACTIVE: LogEntryKind.ALARM_ACTIVE,
AlarmState.ACK: LogEntryKind.ALARM_ACK,
AlarmState.CLEARED: LogEntryKind.ALARM_CLEARED,
}
kind = kind_map.get(alarm.state, LogEntryKind.ALARM_ACTIVE)
try:
loop = asyncio.get_running_loop()
task = loop.create_task(
logbook.append(
kind,
f"[{alarm.priority.value}] {alarm.tag_id}: {alarm.message}",
payload={"alarm_id": alarm.id, "value": alarm.value_at_trigger},
user=alarm.acknowledged_by or "system",
timestamp=alarm.timestamp_ack or alarm.timestamp_active,
)
)
_alarm_log_tasks.add(task)
task.add_done_callback(_alarm_log_tasks.discard)
except RuntimeError:
# No event loop — silencioso (puede ocurrir en teardown)
pass
alarm_engine = AlarmEngine(tag_store, on_alarm_event=_on_alarm_event)
simulator = SimulatorDriver(tag_store, tick_period_s=simulator_tick_s)
engine_log_writer = EngineLogWriter(tag_store, logbook)
snapshot_writer = SnapshotLogWriter(
tag_store, logbook, period_s=snapshot_period_s
)
n2k_publisher = N2KPublisher(tag_store)
n2k_subscriber = N2KSubscriber(tag_store)
return RuntimeApp(
project=project,
@@ -69,5 +129,10 @@ def build_runtime(
historian=historian,
alarm_engine=alarm_engine,
simulator=simulator,
logbook=logbook,
engine_log_writer=engine_log_writer,
snapshot_writer=snapshot_writer,
n2k_publisher=n2k_publisher,
n2k_subscriber=n2k_subscriber,
alarm_events=alarm_events,
)