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:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user