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
+198 -2
View File
@@ -30,6 +30,7 @@ from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import paho.mqtt.client as mqtt
@@ -50,6 +51,15 @@ MQTT_SETTINGS_KEYS = {
}
@dataclass
class _SourceClientState:
"""One installed source generation and its in-flight callback count."""
client: mqtt.Client
generation: int
in_flight: int = 0
def _is_configured(settings: Settings) -> bool:
"""Return True if MQTT is enabled *and* the broker host is set."""
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
@@ -72,11 +82,26 @@ class MqttManager:
def __init__(self) -> None:
self._client: mqtt.Client | None = None
self._lock = threading.Lock()
self._lock = threading.RLock()
# Source replacement/removal is serialized independently from callback
# bookkeeping. In particular, paho's loop_stop() joins its network
# thread, whose callback completion also needs ``_lock``.
self._source_lifecycle_lock = threading.Lock()
self._source_idle = threading.Condition(self._lock)
self._connected = False
# topic → handler registry; persists across reconnects so subscriptions
# are automatically re-established when the client reconnects.
self._subscriptions: dict[str, Callable[[bytes], None]] = {}
# DSMR sources are independent connections: their credentials and TLS
# configuration belong to MeterSource.config, not app_config.
self._source_clients: dict[int, mqtt.Client] = {}
self._source_subscriptions: dict[int, dict[str, Callable[[bytes], None]]] = {}
self._source_connected: set[int] = set()
# Each replacement gets a distinct identity. A paho callback can run
# after its client was stopped, so source id alone is not sufficient.
self._source_generations: dict[int, int] = {}
self._source_states: dict[int, _SourceClientState] = {}
self._next_source_generation = 0
# ------------------------------------------------------------------
# Public properties
@@ -115,6 +140,11 @@ class MqttManager:
"""
with self._lock:
self._stop_client()
with self._source_lifecycle_lock:
with self._lock:
source_ids = list(self._source_clients)
for source_id in source_ids:
self._stop_source_client(source_id)
def reconnect(self, settings: Settings) -> None:
"""Disconnect the current client (if any) and reconnect with *settings*.
@@ -196,8 +226,134 @@ class MqttManager:
except Exception:
logger.exception("MQTT unsubscribe error (topic=%s).", topic)
def replace_source(
self,
source_id: int,
*,
host: str,
port: int,
username: str,
password: str,
tls_enabled: bool,
subscriptions: dict[str, Callable[[bytes], None]],
) -> bool:
"""Replace one source-owned client and its handlers.
This intentionally does not touch the legacy app-wide client or any
other source client. It is also safe for a source to be temporarily
unconfigured: handlers are retained in the source registry but no
connection is attempted until a host is supplied.
"""
with self._source_lifecycle_lock:
self._stop_source_client(source_id)
if not host:
return False
self._next_source_generation += 1
generation = self._next_source_generation
captured_subscriptions = dict(subscriptions)
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"home-automation-dsmr-{source_id}",
)
def _on_connect(
connected_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
with self._lock:
if not self._is_current_source_client(source_id, generation, connected_client):
return
if reason_code.is_failure:
logger.warning("DSMR MQTT connection refused for source_id=%s", source_id)
return
self._source_connected.add(source_id)
for topic in captured_subscriptions:
try:
connected_client.subscribe(topic)
except Exception:
logger.exception("DSMR MQTT re-subscribe failed for source_id=%s", source_id)
def _on_disconnect(
disconnected_client: mqtt.Client,
_userdata: object,
_flags: mqtt.DisconnectFlags,
_reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
with self._lock:
if not self._is_current_source_client(source_id, generation, disconnected_client):
return
self._source_connected.discard(source_id)
def _on_message(
message_client: mqtt.Client,
_userdata: object,
message: mqtt.MQTTMessage,
) -> None:
with self._lock:
if not self._is_current_source_client(source_id, generation, message_client):
return
handler = captured_subscriptions.get(message.topic)
state = self._source_states.get(source_id)
if handler is None or state is None:
return
# This permit covers the entire handler call. Teardown first
# invalidates the state and then waits for all permits, so an
# old callback cannot run after teardown returns.
state.in_flight += 1
try:
handler(message.payload)
except Exception:
logger.exception("DSMR source handler raised (source_id=%s)", source_id)
finally:
with self._lock:
state.in_flight -= 1
if state.in_flight == 0:
self._source_idle.notify_all()
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
client.on_message = _on_message
if tls_enabled:
try:
client.tls_set()
except Exception:
logger.exception("DSMR MQTT TLS setup failed for source_id=%s", source_id)
return False
if username:
client.username_pw_set(username=username, password=password or None)
# Register ownership before network processing begins. A broker
# may deliver CONNACK synchronously from connect(), or on the loop
# thread before connect() returns.
with self._lock:
self._source_clients[source_id] = client
self._source_subscriptions[source_id] = captured_subscriptions
self._source_generations[source_id] = generation
self._source_states[source_id] = _SourceClientState(client, generation)
client.loop_start()
try:
client.connect(host=host, port=port, keepalive=60)
except Exception:
logger.exception("DSMR MQTT connect failed (source_id=%s, host=%s)", source_id, host)
self._stop_source_client(source_id)
return False
return True
def remove_source(self, source_id: int) -> None:
"""Drop one source client and its handlers, including queued callbacks."""
with self._source_lifecycle_lock:
self._stop_source_client(source_id)
def source_is_active(self, source_id: int) -> bool:
"""Whether a source-owned client is currently installed for callbacks."""
with self._lock:
return source_id in self._source_clients
# ------------------------------------------------------------------
# Internal helpers — must be called with self._lock held
# Internal helpers
# ------------------------------------------------------------------
def _start_client(self, settings: Settings) -> None:
@@ -333,6 +489,46 @@ class MqttManager:
logger.debug("MQTT loop_stop raised (ignoring).", exc_info=True)
logger.info("MQTT client stopped.")
def _stop_source_client(self, source_id: int) -> None:
"""Detach then stop a source client without blocking callback bookkeeping.
Callers hold ``_source_lifecycle_lock``. The first phase makes the
generation unreachable while holding ``_lock``. Paho operations and
the in-flight wait are deliberately outside that lock: loop_stop()
joins paho's network thread, and an active callback needs ``_lock`` to
release its permit in ``_on_message``'s finally block.
"""
with self._lock:
state = self._source_states.pop(source_id, None)
client = self._source_clients.pop(source_id, None)
self._source_subscriptions.pop(source_id, None)
self._source_connected.discard(source_id)
# Invalidate callbacks even when there was no successfully
# installed client (for example after a failed replacement).
self._source_generations.pop(source_id, None)
if client is not None:
try:
client.disconnect()
except Exception:
logger.debug("DSMR MQTT disconnect raised (source_id=%s)", source_id, exc_info=True)
try:
client.loop_stop()
except Exception:
logger.debug("DSMR MQTT loop_stop raised (source_id=%s)", source_id, exc_info=True)
if state is not None:
with self._lock:
while state.in_flight:
self._source_idle.wait()
def _is_current_source_client(
self, source_id: int, generation: int, client: mqtt.Client
) -> bool:
"""Check callback ownership while ``_lock`` is held."""
return (
self._source_generations.get(source_id) == generation
and self._source_clients.get(source_id) is client
)
# ---------------------------------------------------------------------------
# Module-level singleton — shared across lifespan and route handlers