M8-T04: reconcile DSMR ingest from meter sources

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent 28486a83c7
commit 2e125dbd53
9 changed files with 1074 additions and 560 deletions
+136 -135
View File
@@ -1,173 +1,174 @@
"""Tests for restart-free DSMR subscription management (apply_dsmr_subscription).
These verify that toggling DSMR ingest / changing its topic / changing its sample
interval via the config UI is reflected in the live MQTT subscription without an
app restart. A fake MQTT manager is injected so no real broker is touched.
"""
"""DSMR source-driven MQTT subscription reconciliation tests."""
from __future__ import annotations
from unittest.mock import MagicMock
from dataclasses import replace
import pytest
from app.services import dsmr_ingest
from app.services.dsmr_ingest import DsmrSourceSnapshot
class _FakeMqtt:
def __init__(self) -> None:
self.subscribe_calls: list[tuple[str, object]] = []
self.unsubscribe_calls: list[str] = []
self.replace_calls: list[tuple[int, dict[str, object]]] = []
self.remove_calls: list[int] = []
self.handlers: dict[int, dict[str, object]] = {}
def subscribe(self, topic: str, handler) -> None:
self.subscribe_calls.append((topic, handler))
def replace_source(self, source_id: int, **kwargs: object) -> bool:
self.replace_calls.append((source_id, kwargs))
self.handlers[source_id] = kwargs["subscriptions"] # type: ignore[assignment]
return True
def unsubscribe(self, topic: str) -> None:
self.unsubscribe_calls.append(topic)
def remove_source(self, source_id: int) -> None:
self.remove_calls.append(source_id)
self.handlers.pop(source_id, None)
def source_is_active(self, source_id: int) -> bool:
return source_id in self.handlers
def _settings(
*,
enabled: bool = True,
topic: str = "dsmr/json",
interval: int = 10,
def _source(
source_id: int,
topic: str,
tariff_topic: str = "",
):
s = MagicMock()
s.dsmr_ingest_enabled = enabled
s.dsmr_mqtt_topic = topic
s.dsmr_sample_interval_s = interval
s.dsmr_tariff_topic = tariff_topic
return s
interval: int = 10,
**connection: object,
) -> DsmrSourceSnapshot:
return DsmrSourceSnapshot(source_id, topic, tariff_topic, interval, **connection)
@pytest.fixture()
def fake_mqtt(monkeypatch):
fake = _FakeMqtt()
# apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager`
# at call time, so patching the module attribute is picked up.
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
# Reset (and auto-restore) the module-level "currently subscribed topics".
monkeypatch.setattr(dsmr_ingest, "_current_dsmr_topic", None)
monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None)
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
return fake
def test_enabled_subscribes_to_topic(fake_mqtt):
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
assert len(fake_mqtt.subscribe_calls) == 1
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
assert fake_mqtt.unsubscribe_calls == []
assert dsmr_ingest._current_dsmr_topic == "dsmr/json"
def test_reconcile_subscribes_each_enabled_source(fake_mqtt, monkeypatch):
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [_source(1, "a"), _source(2, "b")])
dsmr_ingest.apply_dsmr_subscription()
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 2]
def test_disabled_after_enabled_unsubscribes(fake_mqtt):
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
assert dsmr_ingest._current_dsmr_topic is None
def test_changed_source_replaces_only_its_subscription(fake_mqtt, monkeypatch):
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [_source(1, "a"), _source(2, "b")])
dsmr_ingest.apply_dsmr_subscription()
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [_source(1, "changed"), _source(2, "b")])
dsmr_ingest.apply_dsmr_subscription()
assert fake_mqtt.remove_calls == [1]
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 2, 1]
def test_topic_change_unsubscribes_old_subscribes_new(fake_mqtt):
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json"))
dsmr_ingest.apply_dsmr_subscription(_settings(topic="meter/dsmr"))
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
assert [t for t, _ in fake_mqtt.subscribe_calls] == ["dsmr/json", "meter/dsmr"]
assert dsmr_ingest._current_dsmr_topic == "meter/dsmr"
def test_disable_unsubscribes_and_clears_source_tariff(fake_mqtt, monkeypatch):
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [_source(1, "a", "tariff/a")])
dsmr_ingest.apply_dsmr_subscription()
dsmr_ingest.set_current_tariff(1, 2)
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [])
dsmr_ingest.apply_dsmr_subscription()
assert fake_mqtt.remove_calls == [1]
assert dsmr_ingest.get_current_tariff(1) is None
def test_reapply_same_topic_resubscribes_fresh_handler(fake_mqtt):
# A changed sample interval must take effect — the handler is re-bound to a
# fresh settings snapshot, so re-applying the same topic re-subscribes.
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=10))
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=20))
assert len(fake_mqtt.subscribe_calls) == 2
assert fake_mqtt.unsubscribe_calls == [] # same topic, no churn
handler1 = fake_mqtt.subscribe_calls[0][1]
handler2 = fake_mqtt.subscribe_calls[1][1]
assert handler1 is not handler2 # fresh closure carrying the new settings
def test_same_snapshot_has_no_subscription_churn(fake_mqtt, monkeypatch):
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [_source(1, "a")])
dsmr_ingest.apply_dsmr_subscription()
dsmr_ingest.apply_dsmr_subscription()
assert len(fake_mqtt.replace_calls) == 1
assert fake_mqtt.remove_calls == []
def test_disabled_when_never_enabled_is_noop(fake_mqtt):
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
assert fake_mqtt.subscribe_calls == []
assert fake_mqtt.unsubscribe_calls == []
assert dsmr_ingest._current_dsmr_topic is None
# ---------------------------------------------------------------------------
# Tariff topic subscription management
# ---------------------------------------------------------------------------
def test_enabled_with_tariff_topic_subscribes_both(fake_mqtt):
"""When enabled and tariff_topic is non-empty, both topics must be subscribed."""
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
def test_same_topic_on_different_brokers_is_allowed(fake_mqtt, monkeypatch):
monkeypatch.setattr(
dsmr_ingest,
"_enabled_snapshots",
lambda: [_source(1, "same", broker_host="one.test"), _source(2, "same", broker_host="two.test")],
)
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
assert "dsmr/json" in subscribed_topics
assert "dsmr/meter-stats/electricity_tariff" in subscribed_topics
assert len(fake_mqtt.subscribe_calls) == 2
assert dsmr_ingest._current_tariff_topic == "dsmr/meter-stats/electricity_tariff"
dsmr_ingest.apply_dsmr_subscription()
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 2]
def test_enabled_with_empty_tariff_topic_subscribes_only_main(fake_mqtt):
"""When tariff_topic is empty, only the main DSMR topic is subscribed."""
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
@pytest.mark.parametrize(
"sources",
[
[_source(1, "shared", "shared")],
],
)
def test_duplicate_telegram_or_tariff_topic_is_rejected(fake_mqtt, monkeypatch, sources):
"""One MQTT topic cannot safely dispatch to more than one source handler."""
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: sources)
dsmr_ingest.apply_dsmr_subscription()
assert fake_mqtt.replace_calls == []
@pytest.mark.parametrize(
"field,value",
[
("broker_host", "changed.test"),
("broker_port", 2883),
("username", "different-user"),
("password", "different-password"),
("tls_enabled", True),
("sample_interval_s", 30),
("tariff_topic", "tariff/changed"),
],
)
def test_each_source_config_change_replaces_only_that_source(fake_mqtt, monkeypatch, field, value):
first = _source(1, "a", "tariff/a", broker_host="one.test", username="one")
second = _source(2, "b", "tariff/b", broker_host="two.test", username="two")
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [first, second])
dsmr_ingest.apply_dsmr_subscription()
changed = _source(1, "a", "tariff/a", broker_host="one.test", username="one")
changed = replace(changed, **{field: value})
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [changed, second])
dsmr_ingest.apply_dsmr_subscription()
assert fake_mqtt.remove_calls == [1]
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 2, 1]
def test_failed_replace_is_not_marked_applied_and_is_retried(fake_mqtt, monkeypatch):
snapshot = _source(1, "a", broker_host="one.test")
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
original_replace = fake_mqtt.replace_source
outcomes = iter([False, True])
def replace_once_fails(source_id: int, **kwargs: object) -> bool:
original_replace(source_id, **kwargs)
return next(outcomes)
fake_mqtt.replace_source = replace_once_fails # type: ignore[method-assign]
dsmr_ingest.apply_dsmr_subscription()
dsmr_ingest.apply_dsmr_subscription()
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 1]
def test_inactive_manager_source_is_rebuilt_on_next_reconcile(fake_mqtt, monkeypatch):
snapshot = _source(1, "a", broker_host="one.test")
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
dsmr_ingest.apply_dsmr_subscription()
fake_mqtt.handlers.clear() # models MqttManager.disconnect() tearing down clients
dsmr_ingest.apply_dsmr_subscription()
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 1]
def test_same_snapshot_aba_rejects_retained_handler(fake_mqtt, monkeypatch):
"""An equal re-enabled snapshot has a fresh callback identity token."""
snapshot = _source(1, "same", broker_host="one.test")
received: list[tuple[bytes, DsmrSourceSnapshot]] = []
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
monkeypatch.setattr(
dsmr_ingest, "handle_message", lambda payload, captured: received.append((payload, captured))
)
dsmr_ingest.apply_dsmr_subscription()
old_handler = fake_mqtt.handlers[1]["same"]
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [])
dsmr_ingest.apply_dsmr_subscription()
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
dsmr_ingest.apply_dsmr_subscription()
assert len(fake_mqtt.subscribe_calls) == 1
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
assert dsmr_ingest._current_tariff_topic is None
def test_disabled_after_tariff_subscription_unsubscribes_both(fake_mqtt):
"""Disabling ingest must also unsubscribe the tariff topic."""
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
)
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
assert "dsmr/json" in fake_mqtt.unsubscribe_calls
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
assert dsmr_ingest._current_dsmr_topic is None
assert dsmr_ingest._current_tariff_topic is None
def test_tariff_topic_change_resubscribes(fake_mqtt):
"""Changing the tariff topic must unsubscribe the old one and subscribe the new one."""
old_tariff = "dsmr/meter-stats/electricity_tariff"
new_tariff = "meter/tariff"
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic=old_tariff)
)
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic=new_tariff)
)
assert old_tariff in fake_mqtt.unsubscribe_calls
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
assert new_tariff in subscribed_topics
assert dsmr_ingest._current_tariff_topic == new_tariff
def test_tariff_topic_cleared_unsubscribes(fake_mqtt):
"""Setting tariff_topic to empty after it was subscribed must unsubscribe it."""
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
)
dsmr_ingest.apply_dsmr_subscription(
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
)
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
assert dsmr_ingest._current_tariff_topic is None
old_handler(b"stale") # type: ignore[operator]
fake_mqtt.handlers[1]["same"](b"fresh") # type: ignore[operator]
assert received == [(b"fresh", snapshot)]