M8-R02: report DSMR MQTT source health

This commit is contained in:
2026-08-24 06:45:30 +02:00
parent e59c192097
commit d9e82038dc
6 changed files with 483 additions and 9 deletions
+259
View File
@@ -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