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
+255
View File
@@ -14,6 +14,7 @@ Covers:
from __future__ import annotations
import threading
from unittest.mock import MagicMock, patch
from app.integrations.mqtt import MqttManager
@@ -231,6 +232,260 @@ def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None
assert call_count[0] == 2
def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
"""A DSMR source has its own client; replacing it leaves peers untouched."""
manager = MqttManager()
first_client = MagicMock()
second_client = MagicMock()
third_client = MagicMock()
received: list[tuple[str, bytes]] = []
with patch(
"app.integrations.mqtt.mqtt.Client", side_effect=[first_client, second_client, third_client]
):
manager.replace_source(
1,
host="one.test",
port=1884,
username="one-user",
password="one-secret",
tls_enabled=True,
subscriptions={"one/topic": lambda payload: received.append(("one", payload))},
)
manager.replace_source(
2,
host="two.test",
port=2884,
username="two-user",
password="two-secret",
tls_enabled=False,
subscriptions={"two/topic": lambda payload: received.append(("two", payload))},
)
manager.replace_source(
1,
host="changed.test",
port=1885,
username="changed-user",
password="changed-secret",
tls_enabled=False,
subscriptions={"changed/topic": lambda payload: received.append(("changed", payload))},
)
first_client.tls_set.assert_called_once_with()
first_client.username_pw_set.assert_called_once_with(username="one-user", password="one-secret")
first_client.connect.assert_called_once_with(host="one.test", port=1884, keepalive=60)
first_client.disconnect.assert_called_once_with()
second_client.disconnect.assert_not_called()
second_client.connect.assert_called_once_with(host="two.test", port=2884, keepalive=60)
third_client.connect.assert_called_once_with(host="changed.test", port=1885, keepalive=60)
second_client.on_message(second_client, None, _make_mqtt_message("two/topic", b"two"))
third_client.on_message(third_client, None, _make_mqtt_message("changed/topic", b"changed"))
assert received == [("two", b"two"), ("changed", b"changed")]
def test_replaced_source_client_callbacks_cannot_reach_new_generation() -> None:
"""A retained old paho client cannot subscribe, mutate state, or dispatch new handlers."""
manager = MqttManager()
old_client = MagicMock()
new_client = MagicMock()
received: list[tuple[str, bytes]] = []
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]):
assert manager.replace_source(
7,
host="old.test",
port=1883,
username="",
password="",
tls_enabled=False,
subscriptions={"same/topic": lambda payload: received.append(("old", payload))},
)
assert manager.replace_source(
7,
host="new.test",
port=1883,
username="",
password="",
tls_enabled=False,
subscriptions={"same/topic": lambda payload: received.append(("new", payload))},
)
accepted = MagicMock()
accepted.is_failure = False
old_client.on_connect(old_client, None, MagicMock(), accepted, None)
old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale"))
old_client.on_disconnect(old_client, None, MagicMock(), MagicMock(), None)
old_client.subscribe.assert_not_called()
assert received == []
assert 7 not in manager._source_connected
new_client.on_message(new_client, None, _make_mqtt_message("same/topic", b"fresh"))
assert received == [("new", b"fresh")]
def test_removed_then_reenabled_identical_source_rejects_old_callback() -> None:
manager = MqttManager()
old_client = MagicMock()
reenabled_client = MagicMock()
received: list[bytes] = []
kwargs = {
"host": "broker.test",
"port": 1883,
"username": "",
"password": "",
"tls_enabled": False,
"subscriptions": {"same/topic": lambda payload: received.append(payload)},
}
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, reenabled_client]):
assert manager.replace_source(7, **kwargs)
manager.remove_source(7)
assert manager.replace_source(7, **kwargs)
old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale"))
reenabled_client.on_message(reenabled_client, None, _make_mqtt_message("same/topic", b"fresh"))
assert received == [b"fresh"]
def test_source_connect_failure_is_not_active_and_can_be_retried() -> None:
manager = MqttManager()
failed_client = MagicMock()
failed_client.connect.side_effect = OSError("broker down")
recovered_client = MagicMock()
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[failed_client, recovered_client]):
assert not manager.replace_source(
1, host="broker.test", port=1883, username="", password="", tls_enabled=False,
subscriptions={"topic": lambda _payload: None},
)
assert not manager.source_is_active(1)
assert manager.replace_source(
1, host="broker.test", port=1883, username="", password="", tls_enabled=False,
subscriptions={"topic": lambda _payload: None},
)
assert manager.source_is_active(1)
failed_client.loop_stop.assert_called_once_with()
def test_source_tls_failure_is_not_active() -> None:
manager = MqttManager()
failed_client = MagicMock()
failed_client.tls_set.side_effect = OSError("bad TLS")
with patch("app.integrations.mqtt.mqtt.Client", return_value=failed_client):
assert not manager.replace_source(
1, host="broker.test", port=1883, username="", password="", tls_enabled=True,
subscriptions={"topic": lambda _payload: None},
)
assert not manager.source_is_active(1)
def test_source_sync_connack_before_connect_returns_subscribes_all_topics() -> None:
"""Ownership is installed before a synchronous CONNACK callback can run."""
manager = MqttManager()
class SyncConnackClient:
def __init__(self) -> None:
self.subscribed: list[str] = []
def loop_start(self) -> None:
pass
def connect(self, **_kwargs: object) -> None:
accepted = MagicMock()
accepted.is_failure = False
self.on_connect(self, None, MagicMock(), accepted, None)
def subscribe(self, topic: str) -> None:
self.subscribed.append(topic)
def disconnect(self) -> None:
pass
def loop_stop(self) -> None:
pass
client = SyncConnackClient()
with patch("app.integrations.mqtt.mqtt.Client", return_value=client):
assert manager.replace_source(
9,
host="broker.test",
port=1883,
username="",
password="",
tls_enabled=False,
subscriptions={"telegram/topic": lambda _payload: None, "tariff/topic": lambda _payload: None},
)
assert manager.source_is_active(9)
assert client.subscribed == ["telegram/topic", "tariff/topic"]
def test_source_teardown_with_joining_loop_stop_waits_for_callback_before_aba() -> None:
"""loop_stop may join a callback that needs the manager lock to finish."""
manager = MqttManager()
class JoiningClient:
def loop_start(self) -> None:
pass
def connect(self, **_kwargs: object) -> None:
pass
def disconnect(self) -> None:
pass
def loop_stop(self) -> None:
self.callback_thread.join()
old_client = JoiningClient()
new_client = MagicMock()
started = threading.Event()
release = threading.Event()
removed = threading.Event()
received: list[bytes] = []
def old_handler(payload: bytes) -> None:
started.set()
release.wait(timeout=2)
received.append(payload)
kwargs = {
"host": "broker.test",
"port": 1883,
"username": "",
"password": "",
"tls_enabled": False,
"subscriptions": {"same/topic": old_handler},
}
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]):
assert manager.replace_source(7, **kwargs)
callback_thread = threading.Thread(
target=old_client.on_message,
args=(old_client, None, _make_mqtt_message("same/topic", b"old")),
daemon=True,
)
old_client.callback_thread = callback_thread
callback_thread.start()
assert started.wait(timeout=1)
def remove_source() -> None:
manager.remove_source(7)
removed.set()
teardown_thread = threading.Thread(target=remove_source, daemon=True)
teardown_thread.start()
assert not removed.wait(timeout=0.05)
release.set()
assert removed.wait(timeout=1)
callback_thread.join(timeout=1)
teardown_thread.join(timeout=1)
assert not callback_thread.is_alive()
assert not teardown_thread.is_alive()
assert removed.is_set()
with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client):
assert manager.replace_source(7, **kwargs)
old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale"))
assert received == [b"old"]
# ---------------------------------------------------------------------------
# on_connect re-subscribes registered topics
# ---------------------------------------------------------------------------