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
+114
View File
@@ -0,0 +1,114 @@
"""Tests del Log Book naval (Sprint 5)."""
from __future__ import annotations
import asyncio
import pytest
from vmssailor.core.enums import Protocol, UnitSI
from vmssailor.core.tag import Tag
from vmssailor.runtime.server.logbook import (
EngineLogConfig,
EngineLogWriter,
LogBook,
LogEntryKind,
SnapshotLogWriter,
)
from vmssailor.runtime.server.tag_store import TagStore
@pytest.mark.asyncio
async def test_logbook_append_and_query():
lb = LogBook()
e1 = await lb.append(LogEntryKind.MANUAL, "Primera entrada", user="op1")
e2 = await lb.append(LogEntryKind.MANUAL, "Segunda entrada", user="op1")
assert e1.id < e2.id
assert e2.prev_hash == e1.hash
entries = lb.query(limit=10)
assert len(entries) == 2
@pytest.mark.asyncio
async def test_logbook_chain_integrity():
lb = LogBook()
for i in range(5):
await lb.append(LogEntryKind.SNAPSHOT, f"Entry {i}")
ok, broken = lb.verify_chain()
assert ok
assert broken == []
@pytest.mark.asyncio
async def test_logbook_tamper_detection():
"""Si modificamos a mano una entrada, la cadena debe detectarlo."""
lb = LogBook()
await lb.append(LogEntryKind.MANUAL, "A")
await lb.append(LogEntryKind.MANUAL, "B")
# Tamper: cambiar el summary de la entrada 1 directo en SQLite
lb._conn.execute("UPDATE logbook SET summary = 'TAMPERED' WHERE id = 1")
lb._conn.commit()
ok, broken = lb.verify_chain()
assert not ok
assert 1 in broken or 2 in broken
@pytest.mark.asyncio
async def test_engine_log_writer_detects_start_stop():
store = TagStore()
store.register_tag(Tag(id="ME_PORT.RPM", unit_si=UnitSI.RPM, protocol=Protocol.MODBUS_RTU, address=1))
lb = LogBook()
writer = EngineLogWriter(store, lb, config=EngineLogConfig(sustain_seconds=0.0))
await writer.start()
try:
# Arranque: pasar de quieto a corriendo
await store.update("ME_PORT.RPM", 0.0)
await asyncio.sleep(0.05)
await store.update("ME_PORT.RPM", 1500.0)
await asyncio.sleep(0.1)
# Parada
await store.update("ME_PORT.RPM", 0.0)
await asyncio.sleep(0.1)
starts = lb.query(kind=LogEntryKind.ENGINE_START)
stops = lb.query(kind=LogEntryKind.ENGINE_STOP)
assert len(starts) == 1
assert len(stops) == 1
assert "ME_PORT" in starts[0].summary
finally:
await writer.stop()
@pytest.mark.asyncio
async def test_snapshot_writer_takes_snapshot():
store = TagStore()
store.register_tag(Tag(id="ME_PORT.RPM", unit_si=UnitSI.RPM, protocol=Protocol.MODBUS_RTU, address=1))
lb = LogBook()
writer = SnapshotLogWriter(store, lb, period_s=0.1, require_running_engine=True)
await writer.start()
try:
await store.update("ME_PORT.RPM", 1500.0)
await asyncio.sleep(0.25) # Permite al menos un tick de 0.1s
snapshots = lb.query(kind=LogEntryKind.SNAPSHOT)
assert len(snapshots) >= 1
# Sin motor corriendo no debería tomar snapshot
snap = snapshots[0]
assert "tags" in snap.payload
assert "ME_PORT.RPM" in snap.payload["tags"]
finally:
await writer.stop()
@pytest.mark.asyncio
async def test_snapshot_writer_skips_when_no_engine():
store = TagStore()
store.register_tag(Tag(id="ME_PORT.RPM", unit_si=UnitSI.RPM, protocol=Protocol.MODBUS_RTU, address=1))
lb = LogBook()
writer = SnapshotLogWriter(store, lb, period_s=0.1, require_running_engine=True)
await writer.start()
try:
await store.update("ME_PORT.RPM", 0.0)
await asyncio.sleep(0.25)
snapshots = lb.query(kind=LogEntryKind.SNAPSHOT)
assert len(snapshots) == 0
finally:
await writer.stop()
+81
View File
@@ -0,0 +1,81 @@
"""Tests del driver NMEA 2000 (Sprint 5 stub)."""
from __future__ import annotations
import asyncio
import pytest
from vmssailor.core.enums import Protocol, UnitSI
from vmssailor.core.tag import Tag
from vmssailor.runtime.server.nmea2000 import (
PGN_ATTITUDE,
PGN_ENGINE_RAPID,
N2KPublisher,
N2KSubscriber,
PgnFrame,
encode_pgn_attitude,
encode_pgn_engine_rapid,
parse_pgn_attitude,
parse_pgn_engine_rapid,
)
from vmssailor.runtime.server.tag_store import TagStore
def test_attitude_pgn_roundtrip():
data = encode_pgn_attitude(yaw_deg=15.5, pitch_deg=-2.3, roll_deg=8.1)
parsed = parse_pgn_attitude(data)
assert abs(parsed["roll_deg"] - 8.1) < 0.1
assert abs(parsed["pitch_deg"] - (-2.3)) < 0.1
assert abs(parsed["yaw_deg"] - 15.5) < 0.1
def test_engine_rapid_pgn_roundtrip():
data = encode_pgn_engine_rapid(instance=0, rpm=1520.5)
parsed = parse_pgn_engine_rapid(data)
assert parsed["instance"] == 0
assert abs(parsed["rpm"] - 1520.5) < 1.0
@pytest.mark.asyncio
async def test_n2k_publisher_emits_engine_rapid():
store = TagStore()
store.register_tag(Tag(id="ME_PORT.RPM", unit_si=UnitSI.RPM, protocol=Protocol.MODBUS_RTU, address=1))
frames: list[PgnFrame] = []
pub = N2KPublisher(store, transport=frames.append)
await pub.start(period_s=0.05)
try:
await store.update("ME_PORT.RPM", 1500.0)
await asyncio.sleep(0.2)
engine_frames = [f for f in frames if f.pgn == PGN_ENGINE_RAPID]
assert len(engine_frames) >= 1
finally:
await pub.stop()
@pytest.mark.asyncio
async def test_n2k_subscriber_handles_attitude():
store = TagStore()
store.register_tag(Tag(id="VESSEL.ROLL_DEG", unit_si=UnitSI.DEGREE, protocol=Protocol.INTERNAL))
store.register_tag(Tag(id="VESSEL.PITCH_DEG", unit_si=UnitSI.DEGREE, protocol=Protocol.INTERNAL))
sub = N2KSubscriber(store)
await sub.start()
try:
data = encode_pgn_attitude(yaw_deg=0.0, pitch_deg=-2.0, roll_deg=5.0)
sub.inject_frame(
PgnFrame(
pgn=PGN_ATTITUDE,
source=0,
data=data,
timestamp=__import__("datetime").datetime.now(__import__("datetime").timezone.utc),
)
)
await asyncio.sleep(0.1)
roll = store.get("VESSEL.ROLL_DEG")
pitch = store.get("VESSEL.PITCH_DEG")
assert roll is not None and roll.value is not None
assert abs(float(roll.value) - 5.0) < 0.1
assert pitch is not None and pitch.value is not None
assert abs(float(pitch.value) - (-2.0)) < 0.1
finally:
await sub.stop()
+63
View File
@@ -145,6 +145,69 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
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")
+374
View File
@@ -0,0 +1,374 @@
"""Log Book naval básico (Sprint 5).
Auto-detección de:
- Arranque/parada de motores (cuando RPM cruza umbral)
- Eventos de alarma con timestamp + ack
- Snapshots periódicos cada 15 min con motor corriendo
Cada entrada se firma con SHA-256 + previous_hash formando una cadena
inmutable (no requiere autoridad externa).
Sprint 13+: cumplimiento MARPOL/SOLAS, PDF oficial, Oil Record Book separado.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import sqlite3
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import Any
from vmssailor.runtime.server.tag_store import TagStore, TagValue
logger = logging.getLogger(__name__)
class LogEntryKind(StrEnum):
ENGINE_START = "engine_start"
ENGINE_STOP = "engine_stop"
ALARM_ACTIVE = "alarm_active"
ALARM_ACK = "alarm_ack"
ALARM_CLEARED = "alarm_cleared"
AUTHORITY_TRANSFER = "authority_transfer"
OVERRIDE = "override"
SNAPSHOT = "snapshot"
MANUAL = "manual"
@dataclass(slots=True)
class LogEntry:
"""Una entrada inmutable del log book."""
id: int
kind: LogEntryKind
timestamp: datetime
summary: str
payload: dict[str, Any]
user: str = "system"
prev_hash: str = ""
hash: str = ""
def compute_hash(self) -> str:
"""Hash de la entrada. `id` no entra (se asigna post-insert)."""
material = {
"kind": self.kind.value,
"timestamp": self.timestamp.isoformat(),
"summary": self.summary,
"payload": self.payload,
"user": self.user,
"prev_hash": self.prev_hash,
}
blob = json.dumps(material, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS logbook (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
summary TEXT NOT NULL,
payload TEXT NOT NULL,
user TEXT NOT NULL DEFAULT 'system',
prev_hash TEXT NOT NULL DEFAULT '',
hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logbook_ts ON logbook(timestamp);
CREATE INDEX IF NOT EXISTS idx_logbook_kind ON logbook(kind);
"""
class LogBook:
"""Log book con cadena inmutable de hashes (anti-tamper básico)."""
def __init__(self, db_path: Path | str | None = None) -> None:
self._db_path = str(db_path) if db_path else ":memory:"
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.executescript(_SCHEMA_SQL)
self._conn.commit()
self._last_hash = self._load_last_hash()
self._lock = asyncio.Lock()
def _load_last_hash(self) -> str:
row = self._conn.execute(
"SELECT hash FROM logbook ORDER BY id DESC LIMIT 1"
).fetchone()
return row[0] if row else ""
async def append(
self,
kind: LogEntryKind,
summary: str,
payload: dict[str, Any] | None = None,
*,
user: str = "system",
timestamp: datetime | None = None,
) -> LogEntry:
async with self._lock:
ts = timestamp or datetime.now(UTC)
entry = LogEntry(
id=-1,
kind=kind,
timestamp=ts,
summary=summary,
payload=payload or {},
user=user,
prev_hash=self._last_hash,
)
entry.hash = entry.compute_hash()
cur = self._conn.execute(
"INSERT INTO logbook (kind, timestamp, summary, payload, user, prev_hash, hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
entry.kind.value,
entry.timestamp.isoformat(),
entry.summary,
json.dumps(entry.payload, ensure_ascii=False),
entry.user,
entry.prev_hash,
entry.hash,
),
)
self._conn.commit()
entry.id = int(cur.lastrowid)
self._last_hash = entry.hash
return entry
def query(
self,
*,
kind: LogEntryKind | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 500,
) -> list[LogEntry]:
sql = "SELECT id, kind, timestamp, summary, payload, user, prev_hash, hash FROM logbook WHERE 1=1"
params: list[Any] = []
if kind:
sql += " AND kind = ?"
params.append(kind.value)
if since:
sql += " AND timestamp >= ?"
params.append(since.isoformat())
if until:
sql += " AND timestamp <= ?"
params.append(until.isoformat())
sql += " ORDER BY id DESC LIMIT ?"
params.append(limit)
rows = self._conn.execute(sql, params).fetchall()
return [
LogEntry(
id=r[0],
kind=LogEntryKind(r[1]),
timestamp=datetime.fromisoformat(r[2]),
summary=r[3],
payload=json.loads(r[4]),
user=r[5],
prev_hash=r[6],
hash=r[7],
)
for r in rows
]
def verify_chain(self) -> tuple[bool, list[int]]:
"""Verifica integridad de la cadena de hashes.
Devuelve `(ok, broken_ids)`. Si `ok` es True, broken_ids es [].
"""
rows = self._conn.execute(
"SELECT id, kind, timestamp, summary, payload, user, prev_hash, hash FROM logbook ORDER BY id"
).fetchall()
prev = ""
broken: list[int] = []
for r in rows:
entry = LogEntry(
id=r[0],
kind=LogEntryKind(r[1]),
timestamp=datetime.fromisoformat(r[2]),
summary=r[3],
payload=json.loads(r[4]),
user=r[5],
prev_hash=r[6],
hash=r[7],
)
if entry.prev_hash != prev:
broken.append(entry.id)
if entry.compute_hash() != entry.hash:
broken.append(entry.id)
prev = entry.hash
return (not broken, sorted(set(broken)))
def stats(self) -> dict[str, Any]:
from collections import Counter
rows = self._conn.execute("SELECT kind, COUNT(*) FROM logbook GROUP BY kind").fetchall()
counts: Counter[str] = Counter()
for k, n in rows:
counts[k] = int(n)
total = self._conn.execute("SELECT COUNT(*) FROM logbook").fetchone()[0]
return {"total_entries": int(total), "by_kind": dict(counts)}
def close(self) -> None:
self._conn.close()
# ============================================================================
# Auto writers (suscriptos al tag_store y al alarm engine)
# ============================================================================
@dataclass(slots=True)
class EngineLogConfig:
"""Configuración del auto-detector de arranque/parada de motores."""
rpm_running_threshold: float = 600.0
"""Umbral RPM por encima del cual consideramos "motor corriendo"."""
rpm_stopped_threshold: float = 50.0
"""Umbral RPM por debajo del cual consideramos "motor parado"."""
sustain_seconds: float = 5.0
"""Persistencia mínima para confirmar transición (anti-flicker)."""
class EngineLogWriter:
"""Detecta arranques y paradas de motores monitoreando RPM."""
def __init__(
self,
tag_store: TagStore,
logbook: LogBook,
config: EngineLogConfig | None = None,
) -> None:
self._store = tag_store
self._logbook = logbook
self._config = config or EngineLogConfig()
# state: tag_id -> ("running"|"stopped"|"unknown", since_ts)
self._state: dict[str, tuple[str, datetime]] = {}
self._stop = False
self._task: asyncio.Task | None = None
self._sub_q: asyncio.Queue[TagValue] | None = None
async def start(self) -> None:
self._sub_q = self._store.subscribe(maxsize=2048)
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop = True
if self._task:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
if self._sub_q:
self._store.unsubscribe(self._sub_q)
async def _loop(self) -> None:
try:
while not self._stop:
tv = await self._sub_q.get()
if not tv.tag_id.endswith(".RPM"):
continue
if not isinstance(tv.value, (int, float)):
continue
await self._evaluate(tv)
except asyncio.CancelledError:
pass
async def _evaluate(self, tv: TagValue) -> None:
rpm = float(tv.value)
running = rpm > self._config.rpm_running_threshold
stopped = rpm < self._config.rpm_stopped_threshold
if not running and not stopped:
return
new_state = "running" if running else "stopped"
prev = self._state.get(tv.tag_id)
if prev is None:
self._state[tv.tag_id] = (new_state, tv.timestamp)
return
prev_state, prev_ts = prev
if prev_state == new_state:
return
# Transition — check sustain
if (tv.timestamp - prev_ts).total_seconds() < self._config.sustain_seconds:
return
self._state[tv.tag_id] = (new_state, tv.timestamp)
engine_name = tv.tag_id.removesuffix(".RPM")
if new_state == "running":
await self._logbook.append(
LogEntryKind.ENGINE_START,
f"Motor {engine_name} arrancó (RPM={rpm:.0f})",
payload={"engine": engine_name, "rpm": rpm},
timestamp=tv.timestamp,
)
else:
await self._logbook.append(
LogEntryKind.ENGINE_STOP,
f"Motor {engine_name} parado (RPM={rpm:.0f})",
payload={"engine": engine_name, "rpm": rpm},
timestamp=tv.timestamp,
)
class SnapshotLogWriter:
"""Toma snapshots periódicos de tags clave (default cada 15 min con motor corriendo)."""
def __init__(
self,
tag_store: TagStore,
logbook: LogBook,
*,
period_s: float = 900.0,
require_running_engine: bool = True,
) -> None:
self._store = tag_store
self._logbook = logbook
self._period_s = period_s
self._require_running = require_running_engine
self._stop = False
self._task: asyncio.Task | None = None
async def start(self) -> None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop = True
if self._task:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
async def _loop(self) -> None:
try:
while not self._stop:
await asyncio.sleep(self._period_s)
await self._take_snapshot()
except asyncio.CancelledError:
pass
async def _take_snapshot(self) -> None:
if self._require_running and not self._any_engine_running():
return
snapshot: dict[str, Any] = {}
for tag_id, tv in self._store.all_values().items():
snapshot[tag_id] = {
"value": tv.value,
"quality": tv.quality.value,
}
await self._logbook.append(
LogEntryKind.SNAPSHOT,
f"Snapshot periódico ({len(snapshot)} tags)",
payload={"tags": snapshot},
)
def _any_engine_running(self) -> bool:
for tag_id, tv in self._store.all_values().items():
if tag_id.endswith(".RPM") and isinstance(tv.value, (int, float)) and tv.value > 600:
return True
return False
+306
View File
@@ -0,0 +1,306 @@
"""Driver NMEA 2000 (Sprint 5).
Sprint 5 entrega:
- Parser de PGNs relevantes para VMS-Sailor: 127257 (Attitude), 127488/489
(Engine Parameters), 127505 (Fluid Level), 127508 (Battery Status), 129025
(Position Rapid)
- N2KPublisher: convierte tag updates a PGNs y los hace disponibles para envío
- N2KSimulator: produce PGN frames simulados (sin hardware CAN)
El driver REAL contra hardware CAN (USB-CAN, Actisense NGT-1, Yacht Devices
YDNU-02) llega en Sprint 12+ junto con el firmware ESP32.
"""
from __future__ import annotations
import asyncio
import logging
import math
import struct
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from vmssailor.core.enums import Quality
from vmssailor.runtime.server.tag_store import TagStore
logger = logging.getLogger(__name__)
# ============================================================================
# PGN definitions
# ============================================================================
@dataclass(slots=True)
class PgnFrame:
"""Marco NMEA 2000 simplificado."""
pgn: int
source: int
data: bytes
timestamp: datetime
def __str__(self) -> str:
return f"PGN{self.pgn:>6d} src={self.source:>3d} len={len(self.data)} ts={self.timestamp.isoformat()}"
# PGN 127257 — Attitude (yaw, pitch, roll)
PGN_ATTITUDE = 127257
# PGN 127488 — Engine Parameters Rapid (RPM, boost, trim)
PGN_ENGINE_RAPID = 127488
# PGN 127489 — Engine Parameters Dynamic (oil press, oil temp, coolant temp, etc)
PGN_ENGINE_DYNAMIC = 127489
# PGN 127505 — Fluid Level
PGN_FLUID_LEVEL = 127505
# PGN 127508 — Battery Status
PGN_BATTERY = 127508
# PGN 129025 — Position Rapid
PGN_POSITION = 129025
def parse_pgn_attitude(data: bytes) -> dict[str, float]:
"""Parse PGN 127257 Attitude. 8 bytes:
byte 0: SID
bytes 1-2: yaw (int16, 0.0001 rad)
bytes 3-4: pitch (int16, 0.0001 rad)
bytes 5-6: roll (int16, 0.0001 rad)
"""
if len(data) < 7:
raise ValueError("PGN 127257 requires at least 7 bytes")
yaw_raw, pitch_raw, roll_raw = struct.unpack("<hhh", data[1:7])
return {
"yaw_deg": math.degrees(yaw_raw * 0.0001),
"pitch_deg": math.degrees(pitch_raw * 0.0001),
"roll_deg": math.degrees(roll_raw * 0.0001),
}
def encode_pgn_attitude(yaw_deg: float, pitch_deg: float, roll_deg: float, sid: int = 0) -> bytes:
"""Codifica PGN 127257 a bytes (para publicar al backbone)."""
yaw = int(math.radians(yaw_deg) / 0.0001)
pitch = int(math.radians(pitch_deg) / 0.0001)
roll = int(math.radians(roll_deg) / 0.0001)
return struct.pack("<Bhhhh", sid & 0xFF, yaw, pitch, roll, 0)
def parse_pgn_engine_rapid(data: bytes) -> dict[str, float]:
"""Parse PGN 127488 Engine Parameters Rapid.
byte 0: engine instance
bytes 1-2: speed (uint16, 0.25 rpm)
bytes 3-4: boost pressure (uint16, hPa)
byte 5: trim/tilt (int8, %)
"""
if len(data) < 6:
raise ValueError("PGN 127488 requires at least 6 bytes")
instance = data[0]
rpm_raw = struct.unpack("<H", data[1:3])[0]
boost_raw = struct.unpack("<H", data[3:5])[0]
trim_raw = struct.unpack("<b", data[5:6])[0]
return {
"instance": float(instance),
"rpm": rpm_raw * 0.25,
"boost_pa": boost_raw * 100.0, # hPa to Pa
"trim_pct": float(trim_raw),
}
def encode_pgn_engine_rapid(instance: int, rpm: float, boost_pa: float = 0.0, trim_pct: float = 0.0) -> bytes:
rpm_raw = int(rpm / 0.25)
boost_raw = int(boost_pa / 100.0)
return struct.pack("<BHHb", instance & 0xFF, rpm_raw, boost_raw, int(trim_pct))
def parse_pgn_fluid_level(data: bytes) -> dict[str, float]:
"""Parse PGN 127505 Fluid Level.
bits 0-3: instance
bits 4-7: fluid type (0=fuel, 1=water, 2=grey, 3=waste, 4=live well, 5=oil, 6=black)
bytes 2-3: level (int16, 0.004 %)
bytes 4-7: capacity (uint32, 0.1 L)
"""
if len(data) < 8:
raise ValueError("PGN 127505 requires 8 bytes")
instance_type = data[0]
instance = instance_type & 0x0F
fluid_type = (instance_type >> 4) & 0x0F
level_raw = struct.unpack("<h", data[1:3])[0]
capacity_raw = struct.unpack("<I", data[3:7])[0]
return {
"instance": float(instance),
"fluid_type": float(fluid_type),
"level_pct": level_raw * 0.004,
"capacity_l": capacity_raw * 0.1,
}
def parse_pgn_battery(data: bytes) -> dict[str, float]:
"""Parse PGN 127508 Battery Status.
byte 0: instance
bytes 1-2: voltage (uint16, 0.01 V)
bytes 3-4: current (int16, 0.1 A)
bytes 5-6: temperature (uint16, 0.01 K - 273.15 for C)
"""
if len(data) < 7:
raise ValueError("PGN 127508 requires at least 7 bytes")
instance = data[0]
v_raw = struct.unpack("<H", data[1:3])[0]
i_raw = struct.unpack("<h", data[3:5])[0]
t_raw = struct.unpack("<H", data[5:7])[0]
temp_c = (t_raw * 0.01) - 273.15
return {
"instance": float(instance),
"voltage_v": v_raw * 0.01,
"current_a": i_raw * 0.1,
"temperature_c": temp_c,
}
# ============================================================================
# Publisher
# ============================================================================
class N2KPublisher:
"""Publica tag updates como PGNs.
Sprint 5: el `transport` es un callable que recibe `PgnFrame`.
Sprint 12+: real CAN transmit con python-can / Actisense / YDWG-02.
"""
def __init__(
self,
tag_store: TagStore,
*,
transport: Callable[[PgnFrame], None] | None = None,
device_address: int = 99,
) -> None:
self._store = tag_store
self._transport = transport or (lambda _f: None)
self._addr = device_address
self._stop = False
self._task: asyncio.Task | None = None
async def start(self, period_s: float = 0.5) -> None:
self._period_s = period_s
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop = True
if self._task:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
async def _loop(self) -> None:
try:
while not self._stop:
self._publish_engines()
self._publish_attitude_stub()
await asyncio.sleep(self._period_s)
except asyncio.CancelledError:
pass
def _publish_engines(self) -> None:
for i, (tag_id, _tag) in enumerate(self._store.all_tags().items()):
if not tag_id.endswith(".RPM"):
continue
tv = self._store.get(tag_id)
if tv is None or not isinstance(tv.value, (int, float)):
continue
data = encode_pgn_engine_rapid(instance=i, rpm=float(tv.value))
self._transport(
PgnFrame(
pgn=PGN_ENGINE_RAPID,
source=self._addr,
data=data,
timestamp=datetime.now(UTC),
)
)
def _publish_attitude_stub(self) -> None:
# Sprint 5: si hay tags de actitud sintéticos, los publicamos.
roll = self._store.get("VESSEL.ROLL_DEG")
pitch = self._store.get("VESSEL.PITCH_DEG")
if roll is None or pitch is None:
return
if not isinstance(roll.value, (int, float)) or not isinstance(pitch.value, (int, float)):
return
data = encode_pgn_attitude(yaw_deg=0.0, pitch_deg=float(pitch.value), roll_deg=float(roll.value))
self._transport(
PgnFrame(
pgn=PGN_ATTITUDE,
source=self._addr,
data=data,
timestamp=datetime.now(UTC),
)
)
# ============================================================================
# Subscriber (consume PGNs from backbone)
# ============================================================================
class N2KSubscriber:
"""Suscribe a PGNs entrantes y los traduce a tag updates.
Sprint 5: `inject_frame(frame)` para testing.
Sprint 12: bucket de USB-CAN/Actisense alimenta los frames.
"""
def __init__(self, tag_store: TagStore) -> None:
self._store = tag_store
self._frames: asyncio.Queue[PgnFrame] = asyncio.Queue(maxsize=4096)
self._stop = False
self._task: asyncio.Task | None = None
async def start(self) -> None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop = True
if self._task:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
def inject_frame(self, frame: PgnFrame) -> None:
"""Inyecta un frame para testing o para integración con USB-CAN."""
with suppress(asyncio.QueueFull):
self._frames.put_nowait(frame)
async def _loop(self) -> None:
try:
while not self._stop:
frame = await self._frames.get()
await self._handle_frame(frame)
except asyncio.CancelledError:
pass
async def _handle_frame(self, frame: PgnFrame) -> None:
try:
if frame.pgn == PGN_ATTITUDE:
att = parse_pgn_attitude(frame.data)
if "VESSEL.ROLL_DEG" in self._store:
await self._store.update(
"VESSEL.ROLL_DEG", att["roll_deg"], quality=Quality.GOOD
)
if "VESSEL.PITCH_DEG" in self._store:
await self._store.update(
"VESSEL.PITCH_DEG", att["pitch_deg"], quality=Quality.GOOD
)
elif frame.pgn == PGN_ENGINE_RAPID:
eng = parse_pgn_engine_rapid(frame.data)
# Convención: instance N corresponde a tag ME_{N}.RPM si existe
inst = int(eng["instance"])
candidate = f"ME_INST_{inst}.RPM"
if candidate in self._store:
await self._store.update(candidate, eng["rpm"], quality=Quality.GOOD)
except Exception:
logger.exception("Error handling PGN %s", frame.pgn)
+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,
)