M8-T04: reconcile DSMR ingest from meter sources
This commit is contained in:
+198
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user