M8-R02: report DSMR MQTT source health
This commit is contained in:
@@ -31,6 +31,7 @@ from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.energy import DsmrReading
|
||||
from app.models.meter_source import MeterSource
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -174,6 +175,264 @@ def test_second_00_persists_full_frame(dsmr_db):
|
||||
assert payload["phase_voltage_l2"] is None
|
||||
|
||||
|
||||
def test_accepted_message_marks_source_online_and_updates_last_seen(dsmr_db):
|
||||
"""A valid, accepted telegram clears stale diagnostics without changing its payload."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
settings = _make_settings(dsmr_sample_interval_s=10)
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, 1)
|
||||
assert source is not None
|
||||
source.enabled = True
|
||||
source.status = "error"
|
||||
source.last_error = "old error"
|
||||
session.commit()
|
||||
|
||||
_call_handle_message(_SAMPLE_TELEGRAM, settings, SessionLocal)
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, 1)
|
||||
assert source is not None
|
||||
assert source.status == "online"
|
||||
assert source.last_error is None
|
||||
assert source.last_seen_at is not None
|
||||
|
||||
|
||||
def test_source_state_callback_persists_only_current_generation(dsmr_db, monkeypatch):
|
||||
"""Connection callbacks use a short session and stale generations leave health untouched."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
|
||||
snapshot = _make_settings()
|
||||
current = object()
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_tokens", {snapshot.source_id: current})
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, snapshot.source_id)
|
||||
assert source is not None
|
||||
source.enabled = True
|
||||
session.commit()
|
||||
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
|
||||
dsmr_ingest.handle_captured_source_state(snapshot, current, "connecting")
|
||||
dsmr_ingest.handle_captured_source_state(snapshot, current, "error")
|
||||
dsmr_ingest.handle_captured_source_state(snapshot, object(), "online")
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, snapshot.source_id)
|
||||
assert source is not None
|
||||
assert source.status == "error"
|
||||
assert source.last_error == "MQTT connection failed."
|
||||
|
||||
|
||||
def test_source_health_is_isolated_between_sources(dsmr_db, monkeypatch):
|
||||
"""Connection and message health changes only touch their own source row."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
from app.services.dsmr_ingest import DsmrSourceSnapshot
|
||||
|
||||
first = _make_settings()
|
||||
second = DsmrSourceSnapshot(2, "second/topic", "", 10)
|
||||
first_token = object()
|
||||
second_token = object()
|
||||
monkeypatch.setattr(
|
||||
dsmr_ingest, "_subscription_tokens", {first.source_id: first_token, second.source_id: second_token}
|
||||
)
|
||||
with Session(engine) as session:
|
||||
first_source = session.get(MeterSource, first.source_id)
|
||||
assert first_source is not None
|
||||
first_source.enabled = True
|
||||
session.add(MeterSource(
|
||||
id=2, name="Second DSMR", kind="dsmr_mqtt", enabled=True, config={},
|
||||
status="online", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
session.commit()
|
||||
|
||||
with patch.object(dsmr_ingest, "get_session_local", return_value=SessionLocal):
|
||||
dsmr_ingest.handle_captured_source_state(first, first_token, "connecting")
|
||||
dsmr_ingest.handle_captured_source_state(second, second_token, "error")
|
||||
dsmr_ingest.handle_message(_payload(_SAMPLE_TELEGRAM), first)
|
||||
|
||||
with Session(engine) as session:
|
||||
first_source = session.get(MeterSource, first.source_id)
|
||||
second_source = session.get(MeterSource, second.source_id)
|
||||
assert first_source is not None and second_source is not None
|
||||
assert first_source.status == "online"
|
||||
assert first_source.last_error is None
|
||||
assert first_source.last_seen_at is not None
|
||||
assert second_source.status == "error"
|
||||
assert second_source.last_error == "MQTT connection failed."
|
||||
assert second_source.last_seen_at is None
|
||||
|
||||
|
||||
def test_disable_reconcile_persists_unknown_and_rejects_retained_state_callback(dsmr_db, monkeypatch):
|
||||
"""Disable invalidates the generation before clearing a stale online health state."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
|
||||
class FakeMqtt:
|
||||
def __init__(self) -> None:
|
||||
self.removed: list[int] = []
|
||||
self.state_handler = None
|
||||
self.active: set[int] = set()
|
||||
|
||||
def replace_source(self, source_id: int, **kwargs: object) -> bool:
|
||||
self.active.add(source_id)
|
||||
self.state_handler = kwargs["state_handler"]
|
||||
return True
|
||||
|
||||
def remove_source(self, source_id: int) -> None:
|
||||
self.removed.append(source_id)
|
||||
self.active.discard(source_id)
|
||||
|
||||
def source_is_active(self, source_id: int) -> bool:
|
||||
return source_id in self.active
|
||||
|
||||
fake = FakeMqtt()
|
||||
snapshot = _make_settings()
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_tokens", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "get_session_local", lambda: SessionLocal)
|
||||
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, snapshot.source_id)
|
||||
assert source is not None
|
||||
source.enabled = True
|
||||
source.status = "online"
|
||||
session.commit()
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
assert fake.state_handler is not None
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, snapshot.source_id)
|
||||
assert source is not None
|
||||
source.enabled = False
|
||||
session.commit()
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [])
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
fake.state_handler("online")
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, snapshot.source_id)
|
||||
assert source is not None
|
||||
assert source.status == "unknown"
|
||||
assert source.last_error is None
|
||||
assert fake.removed == [snapshot.source_id]
|
||||
|
||||
|
||||
def test_topic_collision_persists_error_when_reconcile_removes_active_client(dsmr_db, monkeypatch):
|
||||
"""A rejected enabled source cannot retain health from its removed client."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
|
||||
class FakeMqtt:
|
||||
def __init__(self) -> None:
|
||||
self.removed: list[int] = []
|
||||
self.active: set[int] = set()
|
||||
|
||||
def replace_source(self, source_id: int, **kwargs: object) -> bool:
|
||||
self.active.add(source_id)
|
||||
return True
|
||||
|
||||
def remove_source(self, source_id: int) -> None:
|
||||
self.removed.append(source_id)
|
||||
self.active.discard(source_id)
|
||||
|
||||
def source_is_active(self, source_id: int) -> bool:
|
||||
return source_id in self.active
|
||||
|
||||
fake = FakeMqtt()
|
||||
valid = _make_settings(dsmr_mqtt_topic="dsmr/telegram")
|
||||
collision = _make_settings(dsmr_mqtt_topic="dsmr/telegram")
|
||||
collision = dsmr_ingest.DsmrSourceSnapshot(
|
||||
collision.source_id,
|
||||
collision.topic,
|
||||
collision.topic,
|
||||
collision.sample_interval_s,
|
||||
)
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_tokens", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "get_session_local", lambda: SessionLocal)
|
||||
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, valid.source_id)
|
||||
assert source is not None
|
||||
source.enabled = True
|
||||
source.status = "online"
|
||||
session.commit()
|
||||
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [valid])
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [collision])
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, valid.source_id)
|
||||
assert source is not None
|
||||
assert source.enabled is True
|
||||
assert source.status == "error"
|
||||
assert source.last_error == "DSMR source configuration invalid."
|
||||
assert fake.removed == [valid.source_id]
|
||||
assert fake.active == set()
|
||||
|
||||
|
||||
def test_topic_collision_persists_error_on_startup_without_runtime_client(dsmr_db, monkeypatch):
|
||||
"""A collision corrects stale persisted online health without an installed client."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
|
||||
collision = dsmr_ingest.DsmrSourceSnapshot(1, "dsmr/telegram", "dsmr/telegram", 10)
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_tokens", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "get_session_local", lambda: SessionLocal)
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [collision])
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, collision.source_id)
|
||||
assert source is not None
|
||||
source.enabled = True
|
||||
source.status = "online"
|
||||
session.commit()
|
||||
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, collision.source_id)
|
||||
assert source is not None
|
||||
assert source.status == "error"
|
||||
assert source.last_error == "DSMR source configuration invalid."
|
||||
|
||||
|
||||
def test_topic_collision_does_not_change_a_disabled_source(dsmr_db, monkeypatch):
|
||||
"""A concurrent disable is not overwritten by collision error handling."""
|
||||
engine, SessionLocal = dsmr_db
|
||||
from app.services import dsmr_ingest
|
||||
|
||||
collision = dsmr_ingest.DsmrSourceSnapshot(1, "dsmr/telegram", "dsmr/telegram", 10)
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_tokens", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "get_session_local", lambda: SessionLocal)
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [collision])
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, collision.source_id)
|
||||
assert source is not None
|
||||
source.enabled = False
|
||||
source.status = "unknown"
|
||||
source.last_error = None
|
||||
session.commit()
|
||||
|
||||
dsmr_ingest.apply_dsmr_subscription()
|
||||
|
||||
with Session(engine) as session:
|
||||
source = session.get(MeterSource, collision.source_id)
|
||||
assert source is not None
|
||||
assert source.status == "unknown"
|
||||
assert source.last_error is None
|
||||
|
||||
|
||||
def test_second_10_persists(dsmr_db):
|
||||
"""A telegram with second=10 (another 10s boundary) must also be persisted."""
|
||||
_, SessionLocal = dsmr_db
|
||||
|
||||
Reference in New Issue
Block a user