36dda85259
Arquitectura asincronica completa para correr 24/7 a bordo del buque.
vmssailor/runtime/server/tag_store.py
- TagStore in-memory con pub/sub asyncio.Queue
- register_tag/register_many con valores iniciales
- TagValue dataclass: value + quality + timestamp + raw_value
- subscribe()/unsubscribe() para fan-out
- stats() con breakdown por Quality
vmssailor/runtime/server/historian.py
- Historian DuckDB embebido (in-memory o archivo)
- Reader loop suscrito al tag_store + buffer + flush periodico (1s)
- query(tag_id, since, until, limit) para series temporales
- Soporta valores numericos y boolean separadamente
vmssailor/runtime/server/alarm_engine.py
- Suscriptor al tag_store que evalua AlarmConfig por update
- Operators: >, >=, <, <=, ==, !=
- Hysteresis correcta: aplica al SALIR de alarma, no a entrar
- Delay configurable (persistencia minima antes de disparar)
- Estados: ACTIVE -> ACK (con user) -> CLEARED
- ack(alarm_id, user) reconoce sin clear
vmssailor/runtime/server/drivers.py
- SimulatorDriver: produce valores sinteticos creibles por UnitSI
- Tick configurable (default 0.5s)
- Respeta range_normal_min/max del tag para mantenerse en rango
- Permite probar UI/API sin hardware ni Modbus real
vmssailor/runtime/server/runtime_app.py
- RuntimeApp dataclass ensambla todos los servicios
- build_runtime(project) construye listo para correr
- start()/stop() async lifecycle ordenado
vmssailor/runtime/server/api.py
- FastAPI app con lifespan que arranca/detiene el runtime
- GET /health, /project
- GET /tags, /tags/{id}, /tags/{id}/history
- GET /alarms, POST /alarms/{id}/ack
- WebSocket /ws/realtime con snapshot inicial + push + heartbeat
runtime_server_main.py
- Entry point con argparse: --vmsproj, --host, --port, --db
- Sin --vmsproj usa proyecto demo Sprint 0 (genera simulator vivo)
- Lanza con uvicorn
Tests (tests/runtime/, 16 nuevos, total 142/142):
- test_tag_store: register, update, subscribe, unsubscribe, stats
- test_historian: roundtrip query, stats
- test_alarm_engine: fire when below, hysteresis clears, ack
- test_api: health, project, tags listing, history, alarms via httpx.ASGITransport
Para correr el servidor en vivo:
uv run python runtime_server_main.py --verbose
Luego en otro shell:
curl http://127.0.0.1:8765/health
curl http://127.0.0.1:8765/tags | jq .
Dependencias agregadas:
- fastapi >=0.110
- uvicorn[standard] >=0.27
- websockets >=12.0
- duckdb >=0.10
- pymodbus >=3.5 (Sprint 5)
- python-can >=4.3 (Sprint 5)
- httpx >=0.27 (testing + cliente HTTP)
142/142 pytest verde, ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.2 KiB
Python
74 lines
2.2 KiB
Python
"""Orquestador del Runtime server: ensambla tag_store + historian + alarm engine + drivers.
|
|
|
|
Sprint 4: simulator-only. Sprint 5: Modbus + NMEA 2000 reales.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from vmssailor.core.alarm import Alarm
|
|
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.tag_store import TagStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RuntimeApp:
|
|
"""Conjunto de servicios del Runtime. Se construye con `build_runtime()`."""
|
|
|
|
project: Project
|
|
tag_store: TagStore
|
|
historian: Historian
|
|
alarm_engine: AlarmEngine
|
|
simulator: SimulatorDriver
|
|
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))
|
|
|
|
async def stop(self) -> None:
|
|
await self.simulator.stop()
|
|
await self.alarm_engine.stop()
|
|
await self.historian.stop()
|
|
logger.info("RuntimeApp stopped")
|
|
|
|
|
|
def build_runtime(
|
|
project: Project,
|
|
*,
|
|
historian_db: Path | str | None = None,
|
|
simulator_tick_s: float = 0.5,
|
|
) -> RuntimeApp:
|
|
"""Construye un Runtime listo para usar (no arrancado todavía)."""
|
|
tag_store = TagStore()
|
|
tag_store.register_many(project.tags)
|
|
|
|
historian = Historian(historian_db)
|
|
|
|
alarm_events: list[Alarm] = []
|
|
|
|
def _on_alarm_event(alarm: Alarm) -> None:
|
|
alarm_events.append(alarm)
|
|
|
|
alarm_engine = AlarmEngine(tag_store, on_alarm_event=_on_alarm_event)
|
|
simulator = SimulatorDriver(tag_store, tick_period_s=simulator_tick_s)
|
|
|
|
return RuntimeApp(
|
|
project=project,
|
|
tag_store=tag_store,
|
|
historian=historian,
|
|
alarm_engine=alarm_engine,
|
|
simulator=simulator,
|
|
alarm_events=alarm_events,
|
|
)
|