Files
alro65 df56f52091 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>
2026-05-17 20:30:41 -04:00

115 lines
3.7 KiB
Python

"""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()