sprint-4: Runtime server base — tag_store + historian + alarm engine + API FastAPI
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>
This commit is contained in:
@@ -20,6 +20,15 @@ dependencies = [
|
|||||||
studio = [
|
studio = [
|
||||||
"PySide6>=6.6,<7.0",
|
"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 = [
|
dev = [
|
||||||
"pytest>=7.4",
|
"pytest>=7.4",
|
||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
@@ -30,6 +39,10 @@ dev = [
|
|||||||
"types-PyYAML",
|
"types-PyYAML",
|
||||||
"types-python-dateutil",
|
"types-python-dateutil",
|
||||||
"PySide6>=6.6,<7.0",
|
"PySide6>=6.6,<7.0",
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.27",
|
||||||
|
"duckdb>=0.10",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
+49
-5
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
print("VMS-Sailor Runtime servidor — Sprint 4 trae el servicio Windows.")
|
parser = argparse.ArgumentParser(prog="vms-runtime-server")
|
||||||
print("En Sprint 0 solo existe el modelo de datos core.")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = "==3.11.*"
|
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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ast-serialize"
|
name = "ast-serialize"
|
||||||
version = "0.4.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -72,6 +115,98 @@ toml = [
|
|||||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
{ 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]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyside6"
|
name = "pyside6"
|
||||||
version = "6.11.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "tomli"
|
name = "tomli"
|
||||||
version = "2.4.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "vmssailor"
|
name = "vmssailor"
|
||||||
version = "0.1.0.dev0"
|
version = "0.1.0.dev0"
|
||||||
@@ -472,6 +690,9 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "duckdb" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pyside6" },
|
{ name = "pyside6" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -481,6 +702,16 @@ dev = [
|
|||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "types-python-dateutil" },
|
{ name = "types-python-dateutil" },
|
||||||
{ name = "types-pyyaml" },
|
{ 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 = [
|
studio = [
|
||||||
{ name = "pyside6" },
|
{ name = "pyside6" },
|
||||||
@@ -488,18 +719,99 @@ studio = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||||
{ name = "pydantic", specifier = ">=2.5,<3.0" },
|
{ 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 == 'dev'", specifier = ">=6.6,<7.0" },
|
||||||
{ name = "pyside6", marker = "extra == 'studio'", 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", marker = "extra == 'dev'", specifier = ">=7.4" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
||||||
{ name = "pytest-qt", marker = "extra == 'dev'", specifier = ">=4.4" },
|
{ 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 = "python-dateutil", specifier = ">=2.8" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||||
{ name = "types-python-dateutil", marker = "extra == 'dev'" },
|
{ name = "types-python-dateutil", marker = "extra == 'dev'" },
|
||||||
{ name = "types-pyyaml", 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"]
|
|
||||||
|
|||||||
@@ -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 +
|
Sprint 4: servidor con tag_store + historian + alarm engine + API FastAPI +
|
||||||
historian + alarm engine + API. Ver `VMS_Sailor_v2_Parte_03_Runtime.md`.
|
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"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Runtime servidor (servicio Windows 24/7 en el PC industrial del buque)."""
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user