From 36dda852594464e1f8f3b4ea2d41fe385999c066 Mon Sep 17 00:00:00 2001 From: Aerom Date: Sun, 17 May 2026 20:03:19 -0400 Subject: [PATCH] =?UTF-8?q?sprint-4:=20Runtime=20server=20base=20=E2=80=94?= =?UTF-8?q?=20tag=5Fstore=20+=20historian=20+=20alarm=20engine=20+=20API?= =?UTF-8?q?=20FastAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pyproject.toml | 13 + runtime_server_main.py | 54 +++- tests/runtime/__init__.py | 0 tests/runtime/test_alarm_engine.py | 92 +++++++ tests/runtime/test_api.py | 92 +++++++ tests/runtime/test_historian.py | 56 ++++ tests/runtime/test_tag_store.py | 64 +++++ uv.lock | 314 ++++++++++++++++++++++- vmssailor/runtime/__init__.py | 12 +- vmssailor/runtime/server/__init__.py | 1 + vmssailor/runtime/server/alarm_engine.py | 184 +++++++++++++ vmssailor/runtime/server/api.py | 171 ++++++++++++ vmssailor/runtime/server/drivers.py | 114 ++++++++ vmssailor/runtime/server/historian.py | 165 ++++++++++++ vmssailor/runtime/server/runtime_app.py | 73 ++++++ vmssailor/runtime/server/tag_store.py | 183 +++++++++++++ 16 files changed, 1579 insertions(+), 9 deletions(-) create mode 100644 tests/runtime/__init__.py create mode 100644 tests/runtime/test_alarm_engine.py create mode 100644 tests/runtime/test_api.py create mode 100644 tests/runtime/test_historian.py create mode 100644 tests/runtime/test_tag_store.py create mode 100644 vmssailor/runtime/server/__init__.py create mode 100644 vmssailor/runtime/server/alarm_engine.py create mode 100644 vmssailor/runtime/server/api.py create mode 100644 vmssailor/runtime/server/drivers.py create mode 100644 vmssailor/runtime/server/historian.py create mode 100644 vmssailor/runtime/server/runtime_app.py create mode 100644 vmssailor/runtime/server/tag_store.py diff --git a/pyproject.toml b/pyproject.toml index 11a514c..12928ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,15 @@ dependencies = [ studio = [ "PySide6>=6.6,<7.0", ] +runtime = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "websockets>=12.0", + "duckdb>=0.10", + "pymodbus>=3.5,<4.0", + "python-can>=4.3", + "httpx>=0.27", +] dev = [ "pytest>=7.4", "pytest-cov>=4.1", @@ -30,6 +39,10 @@ dev = [ "types-PyYAML", "types-python-dateutil", "PySide6>=6.6,<7.0", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "duckdb>=0.10", + "httpx>=0.27", ] [project.scripts] diff --git a/runtime_server_main.py b/runtime_server_main.py index d882d74..407e203 100644 --- a/runtime_server_main.py +++ b/runtime_server_main.py @@ -1,16 +1,60 @@ -"""Entry point del servicio Runtime (stub Sprint 0). +"""Entry point del Runtime servidor. -Sprint 4 lo reemplaza con `vmssailor.runtime.server.service:main`. +Uso: + uv run vms-runtime-server --vmsproj path/to/project.vmsproj + uv run vms-runtime-server (genera proyecto demo y arranca) + +Sin --vmsproj genera el proyecto demo de Sprint 0 y arranca el servidor +con simulator-driver para que puedas probar el cliente y la API. """ from __future__ import annotations +import argparse +import logging import sys +from pathlib import Path -def main() -> int: - print("VMS-Sailor Runtime servidor — Sprint 4 trae el servicio Windows.") - print("En Sprint 0 solo existe el modelo de datos core.") +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="vms-runtime-server") + parser.add_argument( + "--vmsproj", + type=Path, + default=None, + help="Ruta al .vmsproj a cargar. Default: genera proyecto demo Sprint 0.", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--db", type=Path, default=None, help="Historian DuckDB (default: in-memory)") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args(argv) + + from vmssailor.shared.logging_setup import setup_logging + + setup_logging(verbose=args.verbose) + log = logging.getLogger("vms-runtime-server") + + if args.vmsproj: + from vmssailor.core.persistence import load_project + + project = load_project(args.vmsproj) + log.info("Loaded project from %s", args.vmsproj) + else: + from vmssailor.tools.generate_test_project import build_demo_project + + project = build_demo_project() + log.info("No --vmsproj — using built-in demo project") + + from vmssailor.runtime.server.api import create_app + from vmssailor.runtime.server.runtime_app import build_runtime + + runtime = build_runtime(project, historian_db=args.db) + app = create_app(runtime) + + import uvicorn + + uvicorn.run(app, host=args.host, port=args.port, log_level="info" if args.verbose else "warning") return 0 diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/runtime/test_alarm_engine.py b/tests/runtime/test_alarm_engine.py new file mode 100644 index 0000000..90f66b0 --- /dev/null +++ b/tests/runtime/test_alarm_engine.py @@ -0,0 +1,92 @@ +"""Tests del Alarm Engine (Sprint 4).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from vmssailor.core.alarm import Alarm +from vmssailor.core.enums import AlarmPriority, AlarmState, Protocol, UnitSI +from vmssailor.core.tag import AlarmConfig, Tag +from vmssailor.runtime.server.alarm_engine import AlarmEngine +from vmssailor.runtime.server.tag_store import TagStore + + +def _tag_with_alarm(low_threshold: float = 1.5, delay_s: float = 0.0) -> Tag: + return Tag( + id="X.PRESS", + unit_si=UnitSI.BAR, + protocol=Protocol.MODBUS_RTU, + address=1, + alarms=[ + AlarmConfig( + id="X.PRESS.LOW", + threshold=low_threshold, + operator="<", + priority=AlarmPriority.EMERGENCY, + hysteresis=0.2, + delay_seconds=delay_s, + ) + ], + ) + + +@pytest.mark.asyncio +async def test_alarm_fires_when_below_threshold(): + store = TagStore() + store.register_tag(_tag_with_alarm(low_threshold=1.5)) + events: list[Alarm] = [] + engine = AlarmEngine(store, on_alarm_event=events.append) + await engine.start() + try: + await store.update("X.PRESS", 1.0) # debajo del threshold + await asyncio.sleep(0.05) + assert len(events) == 1 + assert events[0].state == AlarmState.ACTIVE + assert events[0].priority == AlarmPriority.EMERGENCY + finally: + await engine.stop() + + +@pytest.mark.asyncio +async def test_alarm_clears_with_hysteresis(): + store = TagStore() + store.register_tag(_tag_with_alarm(low_threshold=1.5)) + events: list[Alarm] = [] + engine = AlarmEngine(store, on_alarm_event=events.append) + await engine.start() + try: + await store.update("X.PRESS", 1.0) + await asyncio.sleep(0.05) + # Subiendo a 1.6 NO debería limpiar (dentro de la hysteresis +0.2 = 1.7) + await store.update("X.PRESS", 1.6) + await asyncio.sleep(0.05) + # Subiendo a 2.0 SÍ limpia + await store.update("X.PRESS", 2.0) + await asyncio.sleep(0.05) + states = [e.state for e in events] + assert AlarmState.ACTIVE in states + assert AlarmState.CLEARED in states + finally: + await engine.stop() + + +@pytest.mark.asyncio +async def test_alarm_ack(): + store = TagStore() + store.register_tag(_tag_with_alarm(low_threshold=1.5)) + events: list[Alarm] = [] + engine = AlarmEngine(store, on_alarm_event=events.append) + await engine.start() + try: + await store.update("X.PRESS", 1.0) + await asyncio.sleep(0.05) + active = engine.active_alarms() + assert len(active) == 1 + ack = engine.ack(active[0].id, user="operator1") + assert ack is not None + assert ack.state == AlarmState.ACK + assert ack.acknowledged_by == "operator1" + finally: + await engine.stop() diff --git a/tests/runtime/test_api.py b/tests/runtime/test_api.py new file mode 100644 index 0000000..b7d2834 --- /dev/null +++ b/tests/runtime/test_api.py @@ -0,0 +1,92 @@ +"""Tests de la API FastAPI del Runtime (Sprint 4).""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from vmssailor.runtime.server.api import create_app +from vmssailor.runtime.server.runtime_app import build_runtime +from vmssailor.tools.generate_test_project import build_demo_project + + +@pytest.fixture +async def runtime_client(): + project = build_demo_project() + runtime = build_runtime(project, simulator_tick_s=0.2) + app = create_app(runtime) + transport = httpx.ASGITransport(app=app) + async with ( + httpx.AsyncClient(transport=transport, base_url="http://test") as client, + app.router.lifespan_context(app), + ): + # Dar un tick para que el simulator produzca al menos un valor + await asyncio.sleep(0.4) + yield client, runtime + + +@pytest.mark.asyncio +async def test_health_endpoint(runtime_client): + client, _runtime = runtime_client + r = await client.get("/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert "tag_store" in body + assert body["tag_store"]["total_tags"] > 0 + + +@pytest.mark.asyncio +async def test_project_endpoint(runtime_client): + client, _runtime = runtime_client + r = await client.get("/project") + assert r.status_code == 200 + body = r.json() + assert body["name"] + assert body["vessel"]["loa_m"] > 0 + + +@pytest.mark.asyncio +async def test_tags_listing_and_simulator_values(runtime_client): + client, _runtime = runtime_client + r = await client.get("/tags") + assert r.status_code == 200 + tags = r.json() + assert len(tags) > 0 + # Al menos alguno debería tener value no nulo después del tick + with_value = [t for t in tags if t["value"] is not None] + assert len(with_value) > 0 + + +@pytest.mark.asyncio +async def test_tag_detail_404(runtime_client): + client, _runtime = runtime_client + r = await client.get("/tags/ghost.tag") + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_history_endpoint(runtime_client): + client, _runtime = runtime_client + # Le damos al simulator más tiempo para que el historian acumule + await asyncio.sleep(1.2) + # Tomamos un tag con valor numérico + list_r = await client.get("/tags") + tag_id = next( + t["id"] for t in list_r.json() if isinstance(t["value"], (int, float)) + ) + r = await client.get(f"/tags/{tag_id}/history") + assert r.status_code == 200 + rows = r.json() + # En 1.2s con simulator de 0.2s -> ~6 muestras + assert len(rows) >= 3 + + +@pytest.mark.asyncio +async def test_alarms_endpoint(runtime_client): + client, _runtime = runtime_client + r = await client.get("/alarms") + assert r.status_code == 200 + assert isinstance(r.json(), list) diff --git a/tests/runtime/test_historian.py b/tests/runtime/test_historian.py new file mode 100644 index 0000000..ad3503a --- /dev/null +++ b/tests/runtime/test_historian.py @@ -0,0 +1,56 @@ +"""Tests del Historian DuckDB (Sprint 4).""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from vmssailor.core.enums import Protocol, UnitSI +from vmssailor.core.tag import Tag +from vmssailor.runtime.server.historian import Historian +from vmssailor.runtime.server.tag_store import TagStore + + +def _tag(tag_id: str) -> Tag: + return Tag(id=tag_id, unit_si=UnitSI.BAR, protocol=Protocol.MODBUS_RTU, address=1) + + +@pytest.mark.asyncio +async def test_historian_records_updates(): + store = TagStore() + store.register_tag(_tag("X")) + h = Historian() # in-memory + await h.start(store) + try: + for v in (4.0, 4.1, 4.2): + await store.update("X", v) + # Force flush + await asyncio.sleep(0.05) + await h._flush(force=True) + rows = h.query( + "X", + since=datetime.now(UTC) - timedelta(minutes=1), + ) + assert len(rows) == 3 + values = [r["value"] for r in rows] + assert values == [4.0, 4.1, 4.2] + finally: + await h.stop() + + +@pytest.mark.asyncio +async def test_historian_stats(): + store = TagStore() + store.register_tag(_tag("X")) + h = Historian() + await h.start(store) + try: + await store.update("X", 1.0) + await asyncio.sleep(0.05) + await h._flush(force=True) + stats = h.stats() + assert stats["total_samples"] >= 1 + finally: + await h.stop() diff --git a/tests/runtime/test_tag_store.py b/tests/runtime/test_tag_store.py new file mode 100644 index 0000000..4ddd341 --- /dev/null +++ b/tests/runtime/test_tag_store.py @@ -0,0 +1,64 @@ +"""Tests del TagStore (Sprint 4).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from vmssailor.core.enums import Protocol, Quality, UnitSI +from vmssailor.core.tag import Tag +from vmssailor.runtime.server.tag_store import TagStore + + +def _make_tag(tag_id: str) -> Tag: + return Tag(id=tag_id, unit_si=UnitSI.BAR, protocol=Protocol.MODBUS_RTU, address=1) + + +@pytest.mark.asyncio +async def test_register_and_get(): + store = TagStore() + t = _make_tag("ME_PORT.OIL_PRESS") + store.register_tag(t, initial_value=4.5) + tv = store.get("ME_PORT.OIL_PRESS") + assert tv is not None + assert tv.value == 4.5 + assert tv.quality == Quality.GOOD + + +@pytest.mark.asyncio +async def test_update_notifies_subscribers(): + store = TagStore() + store.register_tag(_make_tag("X")) + q = store.subscribe() + await store.update("X", 5.0) + tv = await asyncio.wait_for(q.get(), timeout=1.0) + assert tv.tag_id == "X" + assert tv.value == 5.0 + + +@pytest.mark.asyncio +async def test_unsubscribe(): + store = TagStore() + store.register_tag(_make_tag("X")) + q = store.subscribe() + store.unsubscribe(q) + await store.update("X", 5.0) + # No debe haber elementos pendientes + assert q.qsize() == 0 + + +@pytest.mark.asyncio +async def test_update_unknown_raises(): + store = TagStore() + with pytest.raises(KeyError): + await store.update("GHOST", 1.0) + + +@pytest.mark.asyncio +async def test_stats(): + store = TagStore() + store.register_many([_make_tag("A"), _make_tag("B")]) + s = store.stats() + assert s["total_tags"] == 2 + assert s["subscribers"] == 0 diff --git a/uv.lock b/uv.lock index 64bd372..9a6dcc6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.11.*" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -11,6 +20,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "ast-serialize" version = "0.4.0" @@ -34,6 +56,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/78/9387dffccdc55a12734f83aaccc4a987404a217a2a12a1920d8d4585950b/ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407", size = 1079199, upload-time = "2026-05-14T22:44:36.816Z" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -72,6 +115,98 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "duckdb" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/66/744b4931b799a42f8cb9bc7a6f169e7b8e51195b62b246db407fd90bf15f/duckdb-1.5.2.tar.gz", hash = "sha256:638da0d5102b6cb6f7d47f83d0600708ac1d3cb46c5e9aaabc845f9ba4d69246", size = 18017166, upload-time = "2026-04-13T11:30:09.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/b0/d13e7e396d86c245290b3e93f692a2d27c2fe99f857aaf9205003c00c978/duckdb-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f69164b048e498b9e9140a24343108a5ae5f17bfb3485185f55fdf9b1aa924d", size = 30020978, upload-time = "2026-04-13T11:28:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/70/7b/ae1ec7f516394aa55501d1949af1f731be8d9d7433f0acc3f4632a0ba484/duckdb-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81fc4fbf0b5e25840b39ba2a10b78c6953c0314d5d0434191e7898f34ab1bba3", size = 15947821, upload-time = "2026-04-13T11:28:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a5/cae0105e01a85f85ead61723bb42dab14c2f8ec49f91e67a2372c02574a4/duckdb-1.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56d38b3c4e0ef2abb58898d0fd423933999ed535c45e75e9d9f72e1d5fed69b8", size = 14201656, upload-time = "2026-04-13T11:28:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/46c57e8813ac33762bddc9545610ed648751c5b6a379abf2dc6035505ce4/duckdb-1.5.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:376856066c65ccd55fcb3a380bbe33a71ce089fc4623d229ffc6e82251afdb6d", size = 19285181, upload-time = "2026-04-13T11:29:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/67694010693ec8c8c975e6991f48ef886d35ecbdaa2f287234882a403c21/duckdb-1.5.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c69907354ffee94ba8cf782daf0480dab7557f21ce27fffa6c0ea8f74ed4b8e2", size = 21394852, upload-time = "2026-04-13T11:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/52/9f/2b1618c5a93949a70dcf105293db7e27bb2b2cc4aeb1ff46b806f430ec81/duckdb-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:d9b4f5430bf4f05d4c0dc4c55c75def3a5af4be0343be20fa2bfc577343fbfc9", size = 13095526, upload-time = "2026-04-13T11:29:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/cb39e0d94a32f5333e819112fd01439a31f541f9c56a31b66f9bd209704b/duckdb-1.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:2323c1195c10fb2bb982fc0218c730b43d1b92a355d61e68e3c5f3ac9d44c34f", size = 13946215, upload-time = "2026-04-13T11:29:08.672Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -223,6 +358,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymodbus" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/55/eee0782c2ca9fa8e7c3218caca22fe30e850cd6c0c0d475c10b8c19e5a36/pymodbus-3.13.0.tar.gz", hash = "sha256:da4c87afe772787620594c564cd8aa8a4c58ff9786382aba9550fe0ce8879f32", size = 165750, upload-time = "2026-04-12T07:43:23.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/9e/6046aa966c04f72417e7f3ee25cec975f770c934ccf83aa6b0c77400ff12/pymodbus-3.13.0-py3-none-any.whl", hash = "sha256:6ce838690b59ef3da00893699d04dc56e60abfb5b569363b7d6526470a3a44f5", size = 166398, upload-time = "2026-04-12T07:43:21.522Z" }, +] + [[package]] name = "pyside6" version = "6.11.1" @@ -328,6 +472,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, ] +[[package]] +name = "python-can" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/f9/a9d99d36dd33be5badb747801c9255c3c526171a5542092eaacc73350fb8/python_can-4.6.1.tar.gz", hash = "sha256:290fea135d04b8504ebff33889cc6d301e2181a54099116609f940825ffe5005", size = 1206049, upload-time = "2025-08-12T07:44:58.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/34/e4ac153acdbcfba7f48bc73d6586a74c91cc919fcc2e29acbf81be329d1f/python_can-4.6.1-py3-none-any.whl", hash = "sha256:17f95255868a95108dcfcb90565a684dad32d5a3ebb35afd14f739e18c84ff6c", size = 276996, upload-time = "2025-08-12T07:44:56.55Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -340,6 +498,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -403,6 +570,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -460,6 +640,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, +] + [[package]] name = "vmssailor" version = "0.1.0.dev0" @@ -472,6 +690,9 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "duckdb" }, + { name = "fastapi" }, + { name = "httpx" }, { name = "mypy" }, { name = "pyside6" }, { name = "pytest" }, @@ -481,6 +702,16 @@ dev = [ { name = "ruff" }, { name = "types-python-dateutil" }, { name = "types-pyyaml" }, + { name = "uvicorn", extra = ["standard"] }, +] +runtime = [ + { name = "duckdb" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pymodbus" }, + { name = "python-can" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, ] studio = [ { name = "pyside6" }, @@ -488,18 +719,99 @@ studio = [ [package.metadata] requires-dist = [ + { name = "duckdb", marker = "extra == 'dev'", specifier = ">=0.10" }, + { name = "duckdb", marker = "extra == 'runtime'", specifier = ">=0.10" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.110" }, + { name = "fastapi", marker = "extra == 'runtime'", specifier = ">=0.110" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'runtime'", specifier = ">=0.27" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "pydantic", specifier = ">=2.5,<3.0" }, + { name = "pymodbus", marker = "extra == 'runtime'", specifier = ">=3.5,<4.0" }, { name = "pyside6", marker = "extra == 'dev'", specifier = ">=6.6,<7.0" }, { name = "pyside6", marker = "extra == 'studio'", specifier = ">=6.6,<7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, { name = "pytest-qt", marker = "extra == 'dev'", specifier = ">=4.4" }, + { name = "python-can", marker = "extra == 'runtime'", specifier = ">=4.3" }, { name = "python-dateutil", specifier = ">=2.8" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "types-python-dateutil", marker = "extra == 'dev'" }, { name = "types-pyyaml", marker = "extra == 'dev'" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'runtime'", specifier = ">=0.27" }, + { name = "websockets", marker = "extra == 'runtime'", specifier = ">=12.0" }, +] +provides-extras = ["studio", "runtime", "dev"] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] -provides-extras = ["studio", "dev"] diff --git a/vmssailor/runtime/__init__.py b/vmssailor/runtime/__init__.py index 274c87d..18cb2ba 100644 --- a/vmssailor/runtime/__init__.py +++ b/vmssailor/runtime/__init__.py @@ -1,5 +1,11 @@ -"""vmssailor.runtime — Servidor 24/7 y cliente desktop a bordo (Sprint 4+). +"""vmssailor.runtime — Servidor 24/7 y cliente desktop a bordo. -En Sprint 0 está vacío. Sprint 4 trae el servicio Windows + drivers Modbus + -historian + alarm engine + API. Ver `VMS_Sailor_v2_Parte_03_Runtime.md`. +Sprint 4: servidor con tag_store + historian + alarm engine + API FastAPI + +simulator driver. Sprint 5: NMEA 2000 + Log Book. Sprint 6: cliente desktop. + +Entry point: `runtime_server_main.py` en la raíz. """ + +from vmssailor.runtime.server.runtime_app import RuntimeApp, build_runtime + +__all__ = ["RuntimeApp", "build_runtime"] diff --git a/vmssailor/runtime/server/__init__.py b/vmssailor/runtime/server/__init__.py new file mode 100644 index 0000000..0f08b4e --- /dev/null +++ b/vmssailor/runtime/server/__init__.py @@ -0,0 +1 @@ +"""Runtime servidor (servicio Windows 24/7 en el PC industrial del buque).""" diff --git a/vmssailor/runtime/server/alarm_engine.py b/vmssailor/runtime/server/alarm_engine.py new file mode 100644 index 0000000..4144d31 --- /dev/null +++ b/vmssailor/runtime/server/alarm_engine.py @@ -0,0 +1,184 @@ +"""Alarm engine — evalúa AlarmConfig contra valores del tag_store. + +Para cada update de tag, recorre sus AlarmConfig y produce instancias +`Alarm` en estados ACTIVE/ACK/CLEARED. + +Sprint 4: lógica básica. Sprint 8: escalación + permissives integration. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from contextlib import suppress +from datetime import UTC, datetime + +from vmssailor.core.alarm import Alarm +from vmssailor.core.enums import AlarmState +from vmssailor.core.tag import AlarmConfig, Tag +from vmssailor.runtime.server.tag_store import TagStore, TagValue +from vmssailor.shared.ids import make_alarm_instance_id + +logger = logging.getLogger(__name__) + + +def _evaluate(value: float, alarm: AlarmConfig) -> bool: + """¿La condición de alarma está activa según el valor?""" + op = alarm.operator + t = alarm.threshold + if op == ">": + return value > t + if op == "<": + return value < t + if op == ">=": + return value >= t + if op == "<=": + return value <= t + if op == "==": + return value == t + if op == "!=": + return value != t + return False + + +def _evaluate_with_hysteresis( + value: float, alarm: AlarmConfig, currently_active: bool +) -> bool: + """Aplica histéresis para no salir/entrar de alarma con jitter.""" + base = _evaluate(value, alarm) + if alarm.hysteresis == 0: + return base + # Si actualmente activo: requiere cruzar threshold ± hysteresis para salir + if currently_active: + h = alarm.hysteresis + op = alarm.operator + if op in (">", ">="): + # Para salir, value debe ser < threshold - h + return value > (alarm.threshold - h) + if op in ("<", "<="): + return value < (alarm.threshold + h) + return base + + +class AlarmEngine: + """Consume del tag_store y emite eventos de alarma.""" + + def __init__( + self, + tag_store: TagStore, + on_alarm_event: Callable[[Alarm], None] | None = None, + ) -> None: + self._tag_store = tag_store + self._on_event = on_alarm_event or (lambda _a: None) + self._active_alarms: dict[str, Alarm] = {} + # tracking del primer momento que la condicion se vuelve verdadera + # para soportar `delay_seconds` + self._pending: dict[str, datetime] = {} + self._stop = False + self._task: asyncio.Task | None = None + + # ----- Lifecycle --------------------------------------------------- + + async def start(self) -> None: + self._sub_q = self._tag_store.subscribe(maxsize=4096) + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._stop = True + if self._task is not None: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + self._tag_store.unsubscribe(self._sub_q) + + async def _loop(self) -> None: + try: + while not self._stop: + tv = await self._sub_q.get() + await self._evaluate_tag(tv) + except asyncio.CancelledError: + pass + + # ----- Evaluation -------------------------------------------------- + + async def _evaluate_tag(self, tv: TagValue) -> None: + if not isinstance(tv.value, (int, float)) or isinstance(tv.value, bool): + return + tag = self._tag_store.get_tag(tv.tag_id) + if tag is None or not tag.alarms: + return + for a in tag.alarms: + await self._evaluate_alarm(tag, a, float(tv.value), tv.timestamp) + + async def _evaluate_alarm( + self, + tag: Tag, + config: AlarmConfig, + value: float, + ts: datetime, + ) -> None: + key = f"{tag.id}|{config.id}" + currently_active = key in self._active_alarms + + condition = _evaluate_with_hysteresis(value, config, currently_active) + + if condition and not currently_active: + # Check delay: si delay_seconds > 0 requiere persistencia previa + if config.delay_seconds > 0: + first_seen = self._pending.get(key) + if first_seen is None: + self._pending[key] = ts + return + if (ts - first_seen).total_seconds() < config.delay_seconds: + return + # Disparar + self._pending.pop(key, None) + alarm = Alarm( + id=make_alarm_instance_id(tag.id, config.id, ts.timestamp()), + tag_id=tag.id, + alarm_config_id=config.id, + priority=config.priority, + state=AlarmState.ACTIVE, + timestamp_active=ts, + message=config.message or f"{tag.id} {config.operator} {config.threshold}", + value_at_trigger=value, + ) + self._active_alarms[key] = alarm + self._on_event(alarm) + logger.info("ALARM ACTIVE %s = %s (%s)", tag.id, value, config.priority.value) + return + + if not condition and currently_active: + # Clear + self._pending.pop(key, None) + alarm = self._active_alarms.pop(key) + cleared = alarm.model_copy( + update={"state": AlarmState.CLEARED, "timestamp_cleared": ts} + ) + self._on_event(cleared) + logger.info("ALARM CLEARED %s", tag.id) + return + + if not condition: + self._pending.pop(key, None) + + def ack(self, alarm_id: str, user: str) -> Alarm | None: + """Reconoce una alarma activa por su id.""" + for key, a in list(self._active_alarms.items()): + if a.id == alarm_id: + ts = datetime.now(UTC) + acked = a.model_copy( + update={ + "state": AlarmState.ACK, + "timestamp_ack": ts, + "acknowledged_by": user, + } + ) + self._active_alarms[key] = acked + self._on_event(acked) + return acked + return None + + def active_alarms(self) -> list[Alarm]: + return list(self._active_alarms.values()) diff --git a/vmssailor/runtime/server/api.py b/vmssailor/runtime/server/api.py new file mode 100644 index 0000000..bf181b2 --- /dev/null +++ b/vmssailor/runtime/server/api.py @@ -0,0 +1,171 @@ +"""API FastAPI del Runtime server. + +- WebSocket: `/ws/realtime` — pub/sub de tag updates + alarm events +- REST: `/tags`, `/tags/{id}/history`, `/alarms`, `/health`, `/project` +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager, suppress +from datetime import datetime +from typing import Any + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect + +from vmssailor.runtime.server.runtime_app import RuntimeApp +from vmssailor.version import __version__ + +logger = logging.getLogger(__name__) + + +def create_app(runtime: RuntimeApp) -> FastAPI: + """Construye la FastAPI app y monta endpoints.""" + + @asynccontextmanager + async def lifespan(_app: FastAPI): + await runtime.start() + try: + yield + finally: + await runtime.stop() + + app = FastAPI( + title="VMS-Sailor Runtime API", + version=__version__, + description="On-board Runtime API: tags, history, alarms, control.", + lifespan=lifespan, + ) + app.state.runtime = runtime + + # ----- Health ------------------------------------------------------ + + @app.get("/health") + def health() -> dict[str, Any]: + return { + "status": "ok", + "vmssailor_version": __version__, + "tag_store": runtime.tag_store.stats(), + "historian": runtime.historian.stats(), + "active_alarms": len(runtime.alarm_engine.active_alarms()), + "project": { + "id": runtime.project.id, + "name": runtime.project.name, + "vessel": runtime.project.vessel.name, + }, + } + + # ----- Project ---------------------------------------------------- + + @app.get("/project") + def project() -> dict[str, Any]: + p = runtime.project + return { + "id": p.id, + "name": p.name, + "customer": p.customer, + "vessel": { + "id": p.vessel.id, + "name": p.vessel.name, + "type": p.vessel.type.value, + "loa_m": p.vessel.length_overall_m, + "beam_m": p.vessel.beam_max_m, + "draft_m": p.vessel.draft_m, + }, + "systems_enabled": [s.value for s in p.systems_enabled], + "stats": p.stats(), + } + + # ----- Tags -------------------------------------------------------- + + @app.get("/tags") + def list_tags() -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for tag_id, tag in runtime.tag_store.all_tags().items(): + tv = runtime.tag_store.get(tag_id) + out.append( + { + "id": tag_id, + "description": tag.description, + "unit_si": tag.unit_si.value, + "controllable": tag.controllable, + "value": tv.value if tv else None, + "quality": tv.quality.value if tv else None, + "timestamp": tv.timestamp.isoformat() if tv else None, + "range_normal_min": tag.range_normal_min, + "range_normal_max": tag.range_normal_max, + } + ) + return out + + @app.get("/tags/{tag_id}") + def get_tag(tag_id: str) -> dict[str, Any]: + tag = runtime.tag_store.get_tag(tag_id) + tv = runtime.tag_store.get(tag_id) + if tag is None or tv is None: + raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.") + return { + "id": tag_id, + "description": tag.description, + "unit_si": tag.unit_si.value, + "value": tv.value, + "quality": tv.quality.value, + "timestamp": tv.timestamp.isoformat(), + "alarms": [a.model_dump(mode="json") for a in tag.alarms], + } + + @app.get("/tags/{tag_id}/history") + def history( + tag_id: str, + since: str | None = None, + until: str | None = None, + limit: int = 1000, + ) -> list[dict[str, Any]]: + if tag_id not in runtime.tag_store: + raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.") + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit) + + # ----- Alarms ------------------------------------------------------ + + @app.get("/alarms") + def alarms(state: str | None = None) -> list[dict[str, Any]]: + active = runtime.alarm_engine.active_alarms() + if state == "active": + return [a.model_dump(mode="json") for a in active] + all_events = runtime.alarm_events + return [a.model_dump(mode="json") for a in all_events] + + @app.post("/alarms/{alarm_id}/ack") + def ack_alarm(alarm_id: str, user: str = "anonymous") -> dict[str, Any]: + acked = runtime.alarm_engine.ack(alarm_id, user=user) + if acked is None: + raise HTTPException(status_code=404, detail=f"Alarma '{alarm_id}' no activa.") + return acked.model_dump(mode="json") + + # ----- WebSocket --------------------------------------------------- + + @app.websocket("/ws/realtime") + async def ws_realtime(ws: WebSocket) -> None: + await ws.accept() + q = runtime.tag_store.subscribe(maxsize=512) + # Push current snapshot first + for tv in runtime.tag_store.all_values().values(): + await ws.send_json(tv.to_event_dict()) + try: + while True: + try: + tv = await asyncio.wait_for(q.get(), timeout=30.0) + await ws.send_json(tv.to_event_dict()) + except TimeoutError: + await ws.send_json({"type": "heartbeat"}) + except WebSocketDisconnect: + pass + finally: + runtime.tag_store.unsubscribe(q) + with suppress(Exception): + await ws.close() + + return app diff --git a/vmssailor/runtime/server/drivers.py b/vmssailor/runtime/server/drivers.py new file mode 100644 index 0000000..905696c --- /dev/null +++ b/vmssailor/runtime/server/drivers.py @@ -0,0 +1,114 @@ +"""Drivers de protocolo (Sprint 4: Simulator). + +Sprint 5 traerá los drivers reales: +- modbus_rtu.py (pymodbus, serial COM) +- nmea2000.py (python-can) +- j1939.py +- card_discovery.py + +Por ahora, `SimulatorDriver` produce valores sintéticos creíbles para los +tags registrados. Sirve para: +- Probar el alarm engine + historian sin hardware +- Probar el cliente desktop y mobile contra datos vivos +- Tests de integración +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import random +from collections.abc import Callable +from contextlib import suppress + +from vmssailor.core.enums import Quality, UnitSI +from vmssailor.runtime.server.tag_store import TagStore + +logger = logging.getLogger(__name__) + + +class SimulatorDriver: + """Genera valores sintéticos para todos los tags numéricos del store. + + Estrategia por unidad: + - rpm: oscila entre 1200 y 1800 ± ruido + - bar: estable cerca del medio del rango con jitter + - C (temperatura): se calienta hacia 80 °C con jitter + - %: 30-70 % oscilación + - V: 27-28 V (24V system) o 13-14 V (12V system) + - bool: random toggle ocasional + + Tags pueden tener `range_normal_min/max`; respeta esos rangos. + """ + + def __init__(self, tag_store: TagStore, tick_period_s: float = 0.5) -> None: + self._store = tag_store + self._tick_s = tick_period_s + self._t0 = 0.0 + self._stop = False + self._task: asyncio.Task | None = None + self._on_tick: Callable[[], None] | 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 is not None: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + + async def _loop(self) -> None: + try: + while not self._stop: + await self._tick() + await asyncio.sleep(self._tick_s) + except asyncio.CancelledError: + pass + + async def _tick(self) -> None: + self._t0 += self._tick_s + for tag_id, tag in self._store.all_tags().items(): + value = self._synthesize(tag_id, tag.unit_si, tag) + if value is None: + continue + await self._store.update(tag_id, value, quality=Quality.GOOD) + if self._on_tick is not None: + self._on_tick() + + def _synthesize(self, tag_id: str, unit: UnitSI, tag) -> float | bool | None: + t = self._t0 + if unit == UnitSI.RPM: + base = 1500 + return base + 250 * math.sin(t / 30 + hash(tag_id) % 100) + random.uniform(-15, 15) + if unit == UnitSI.BAR: + mid = ( + (tag.range_normal_min + tag.range_normal_max) / 2 + if tag.range_normal_min is not None and tag.range_normal_max is not None + else 4.5 + ) + return mid + 0.2 * math.sin(t / 5 + hash(tag_id) % 50) + random.uniform(-0.05, 0.05) + if unit == UnitSI.DEGREE_CELSIUS: + target = 82.0 + return target + 8 * math.sin(t / 60 + hash(tag_id) % 30) + random.uniform(-0.5, 0.5) + if unit == UnitSI.PERCENT: + return 50 + 25 * math.sin(t / 20 + hash(tag_id) % 20) + random.uniform(-2, 2) + if unit == UnitSI.VOLT: + mid = ( + (tag.range_normal_min + tag.range_normal_max) / 2 + if tag.range_normal_min is not None and tag.range_normal_max is not None + else 27.5 + ) + return mid + random.uniform(-0.2, 0.2) + if unit == UnitSI.HERTZ: + return 50.0 + random.uniform(-0.1, 0.1) + if unit == UnitSI.AMPERE: + return 50 + 30 * math.sin(t / 25 + hash(tag_id) % 40) + if unit == UnitSI.HOUR: + return 1200 + t / 3600 # accumulating engine hours + if unit == UnitSI.BOOL: + # 99% del tiempo False, eventualmente True + return random.random() < 0.02 + return None diff --git a/vmssailor/runtime/server/historian.py b/vmssailor/runtime/server/historian.py new file mode 100644 index 0000000..de69b30 --- /dev/null +++ b/vmssailor/runtime/server/historian.py @@ -0,0 +1,165 @@ +"""Historian DuckDB embebido. + +Almacena series temporales de los tags con retención configurable. + +Para Sprint 4 implementamos: +- Inserción batch (cada 1s) desde el subscriber del tag_store +- Query simple por tag_id + rango temporal + +Sprint 5+: downsampling automático, compresión. +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import duckdb + +from vmssailor.runtime.server.tag_store import TagStore, TagValue + +logger = logging.getLogger(__name__) + + +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS samples ( + tag_id VARCHAR NOT NULL, + timestamp TIMESTAMP NOT NULL, + value DOUBLE, + bool_value BOOLEAN, + quality VARCHAR NOT NULL DEFAULT 'good' +); +CREATE INDEX IF NOT EXISTS idx_samples_tag_ts ON samples(tag_id, timestamp); +""" + + +class Historian: + """DuckDB-backed historian. Apenas un wrapper async-friendly.""" + + def __init__(self, db_path: Path | str | None = None) -> None: + if db_path is None: + db_path = ":memory:" + self._db_path = str(db_path) + self._conn = duckdb.connect(self._db_path) + self._conn.execute(_SCHEMA_SQL) + self._buffer: list[tuple] = [] + self._buffer_lock = asyncio.Lock() + self._flush_task: asyncio.Task | None = None + self._stopping = False + + # ----- Lifecycle --------------------------------------------------- + + async def start(self, tag_store: TagStore) -> None: + """Comienza a suscribirse al tag store y a flush periodico.""" + self._tag_store = tag_store + self._subscriber_q = tag_store.subscribe(maxsize=8192) + self._reader_task = asyncio.create_task(self._reader_loop()) + self._flush_task = asyncio.create_task(self._flush_loop()) + + async def stop(self) -> None: + self._stopping = True + for t in (getattr(self, "_reader_task", None), self._flush_task): + if t is not None: + t.cancel() + with suppress(asyncio.CancelledError): + await t + if hasattr(self, "_subscriber_q"): + self._tag_store.unsubscribe(self._subscriber_q) + await self._flush(force=True) + self._conn.close() + + # ----- Loops ------------------------------------------------------- + + async def _reader_loop(self) -> None: + while not self._stopping: + try: + tv: TagValue = await self._subscriber_q.get() + except asyncio.CancelledError: + break + await self._enqueue(tv) + + async def _flush_loop(self) -> None: + try: + while not self._stopping: + await asyncio.sleep(1.0) + await self._flush() + except asyncio.CancelledError: + pass + + async def _enqueue(self, tv: TagValue) -> None: + async with self._buffer_lock: + value_num: float | None + value_bool: bool | None + if isinstance(tv.value, bool): + value_num = None + value_bool = tv.value + elif isinstance(tv.value, (int, float)): + value_num = float(tv.value) + value_bool = None + else: + value_num = None + value_bool = None + self._buffer.append( + (tv.tag_id, tv.timestamp, value_num, value_bool, tv.quality.value) + ) + + async def _flush(self, force: bool = False) -> None: + async with self._buffer_lock: + if not self._buffer: + return + batch = self._buffer + self._buffer = [] + try: + # DuckDB executemany es síncrono pero rápido + self._conn.executemany( + "INSERT INTO samples (tag_id, timestamp, value, bool_value, quality) " + "VALUES (?, ?, ?, ?, ?)", + batch, + ) + except Exception: + logger.exception("Failed to flush %d samples", len(batch)) + + # ----- Queries ----------------------------------------------------- + + def query( + self, + tag_id: str, + *, + since: datetime | None = None, + until: datetime | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Consulta histórica de un tag.""" + if since is None: + since = datetime.now(UTC) - timedelta(hours=1) + if until is None: + until = datetime.now(UTC) + sql = ( + "SELECT tag_id, timestamp, value, bool_value, quality " + "FROM samples WHERE tag_id = ? AND timestamp >= ? AND timestamp <= ? " + "ORDER BY timestamp" + ) + if limit: + sql += f" LIMIT {int(limit)}" + rows = self._conn.execute(sql, [tag_id, since, until]).fetchall() + result: list[dict[str, Any]] = [] + for r in rows: + value = r[2] if r[2] is not None else r[3] + result.append( + { + "tag_id": r[0], + "timestamp": r[1].isoformat(), + "value": value, + "quality": r[4], + } + ) + return result + + def stats(self) -> dict[str, Any]: + total = self._conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + tags = self._conn.execute("SELECT COUNT(DISTINCT tag_id) FROM samples").fetchone()[0] + return {"total_samples": int(total), "tags": int(tags), "db_path": self._db_path} diff --git a/vmssailor/runtime/server/runtime_app.py b/vmssailor/runtime/server/runtime_app.py new file mode 100644 index 0000000..46b92ae --- /dev/null +++ b/vmssailor/runtime/server/runtime_app.py @@ -0,0 +1,73 @@ +"""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, + ) diff --git a/vmssailor/runtime/server/tag_store.py b/vmssailor/runtime/server/tag_store.py new file mode 100644 index 0000000..6db7e02 --- /dev/null +++ b/vmssailor/runtime/server/tag_store.py @@ -0,0 +1,183 @@ +"""Tag store en memoria con pub/sub asíncrono. + +Esta es la "fuente de verdad" del estado del buque en tiempo real. + +Cada Tag está registrado por su `id` y tiene: +- Valor actual + calidad + timestamp +- Lista de subscribers asyncio.Queue para fan-out a: + - Alarm engine + - Historian + - WebSocket clients + - Authority/permissive engines +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from vmssailor.core.enums import Quality +from vmssailor.core.tag import Tag + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class TagValue: + """Snapshot del valor actual de un tag.""" + + tag_id: str + value: float | bool | str | None + quality: Quality + timestamp: datetime + raw_value: float | None = None # valor crudo pre-escalado, útil para debug + + def to_event_dict(self) -> dict[str, Any]: + """Formato para enviar por WebSocket / alarmas / historian.""" + return { + "type": "tag_update", + "tag_id": self.tag_id, + "value": self.value, + "quality": self.quality.value, + "timestamp": self.timestamp.isoformat(), + } + + +@dataclass(slots=True) +class _TagEntry: + tag: Tag + value: TagValue + last_quality_change: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class TagStore: + """Estado de runtime en memoria con pub/sub asíncrono. + + Uso: + + store = TagStore() + store.register_tag(tag_config) + await store.update("ME_PORT.OIL_PRESS", 4.8) + async for ev in store.subscribe(): + ... + """ + + def __init__(self) -> None: + self._entries: dict[str, _TagEntry] = {} + self._subscribers: set[asyncio.Queue[TagValue]] = set() + self._lock = asyncio.Lock() + + # ----- Registro ----------------------------------------------------- + + def register_tag(self, tag: Tag, initial_value: Any = None) -> None: + """Registra un tag con valor inicial. + + Si ya existe, mantiene el valor actual. + """ + if tag.id in self._entries: + return + ts = datetime.now(UTC) + self._entries[tag.id] = _TagEntry( + tag=tag, + value=TagValue( + tag_id=tag.id, + value=initial_value, + quality=Quality.UNCERTAIN if initial_value is None else Quality.GOOD, + timestamp=ts, + ), + ) + + def register_many(self, tags: list[Tag]) -> None: + for t in tags: + self.register_tag(t) + + # ----- Lectura ------------------------------------------------------ + + def get(self, tag_id: str) -> TagValue | None: + entry = self._entries.get(tag_id) + return entry.value if entry else None + + def get_tag(self, tag_id: str) -> Tag | None: + entry = self._entries.get(tag_id) + return entry.tag if entry else None + + def all_values(self) -> dict[str, TagValue]: + return {tid: e.value for tid, e in self._entries.items()} + + def all_tags(self) -> dict[str, Tag]: + return {tid: e.tag for tid, e in self._entries.items()} + + def __len__(self) -> int: + return len(self._entries) + + def __contains__(self, tag_id: str) -> bool: + return tag_id in self._entries + + # ----- Escritura ---------------------------------------------------- + + async def update( + self, + tag_id: str, + value: float | bool | str, + *, + quality: Quality = Quality.GOOD, + raw_value: float | None = None, + timestamp: datetime | None = None, + ) -> TagValue: + """Actualiza el valor de un tag y notifica a todos los subscribers.""" + entry = self._entries.get(tag_id) + if entry is None: + raise KeyError(f"Tag '{tag_id}' no está registrado en el tag_store.") + ts = timestamp or datetime.now(UTC) + new_value = TagValue( + tag_id=tag_id, + value=value, + quality=quality, + timestamp=ts, + raw_value=raw_value, + ) + async with self._lock: + old = entry.value + entry.value = new_value + if old.quality != quality: + entry.last_quality_change = ts + + # Notificar a todos los subscribers (no-blocking) + for q in list(self._subscribers): + try: + q.put_nowait(new_value) + except asyncio.QueueFull: + logger.warning("Subscriber queue full, dropping update for %s", tag_id) + return new_value + + # ----- Pub/Sub ------------------------------------------------------ + + def subscribe(self, maxsize: int = 1024) -> asyncio.Queue[TagValue]: + """Devuelve una Queue donde van los updates. + + El subscriber DEBE llamar `unsubscribe(queue)` al terminar para + liberar la referencia. + """ + q: asyncio.Queue[TagValue] = asyncio.Queue(maxsize=maxsize) + self._subscribers.add(q) + return q + + def unsubscribe(self, queue: asyncio.Queue[TagValue]) -> None: + self._subscribers.discard(queue) + + # ----- Stats -------------------------------------------------------- + + def stats(self) -> dict[str, Any]: + from collections import Counter + + quality_counts: Counter[str] = Counter() + for e in self._entries.values(): + quality_counts[e.value.quality.value] += 1 + return { + "total_tags": len(self._entries), + "subscribers": len(self._subscribers), + "by_quality": dict(quality_counts), + }