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
+29 -7
View File
@@ -248,6 +248,7 @@ class MqttManager:
tls_enabled: bool,
subscriptions: dict[str, Callable[[bytes], None]],
base_client_id: str = "home-automation",
state_handler: Callable[[str], None] | None = None,
) -> bool:
"""Replace one source-owned client and its handlers.
@@ -259,6 +260,7 @@ class MqttManager:
with self._source_lifecycle_lock:
self._stop_source_client(source_id)
if not host:
self._report_source_state(state_handler, "error", source_id)
return False
self._next_source_generation += 1
generation = self._next_source_generation
@@ -279,14 +281,18 @@ class MqttManager:
if not self._is_current_source_client(source_id, generation, connected_client):
return
if reason_code.is_failure:
self._source_connected.discard(source_id)
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)
state = "error"
else:
self._source_connected.add(source_id)
state = "online"
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)
self._report_source_state(state_handler, state, source_id)
def _on_disconnect(
disconnected_client: mqtt.Client,
@@ -299,6 +305,7 @@ class MqttManager:
if not self._is_current_source_client(source_id, generation, disconnected_client):
return
self._source_connected.discard(source_id)
self._report_source_state(state_handler, "error", source_id)
def _on_message(
message_client: mqtt.Client,
@@ -334,6 +341,7 @@ class MqttManager:
client.tls_set()
except Exception:
logger.exception("DSMR MQTT TLS setup failed for source_id=%s", source_id)
self._report_source_state(state_handler, "error", source_id)
return False
if username:
client.username_pw_set(username=username, password=password or None)
@@ -345,11 +353,13 @@ class MqttManager:
self._source_subscriptions[source_id] = captured_subscriptions
self._source_generations[source_id] = generation
self._source_states[source_id] = _SourceClientState(client, generation)
self._report_source_state(state_handler, "connecting", source_id)
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._report_source_state(state_handler, "error", source_id)
self._stop_source_client(source_id)
return False
return True
@@ -541,6 +551,18 @@ class MqttManager:
and self._source_clients.get(source_id) is client
)
@staticmethod
def _report_source_state(
state_handler: Callable[[str], None] | None, state: str, source_id: int
) -> None:
"""Invoke an optional health callback without exposing connection credentials."""
if state_handler is None:
return
try:
state_handler(state)
except Exception:
logger.exception("DSMR MQTT source state update failed for source_id=%s", source_id)
# ---------------------------------------------------------------------------
# Module-level singleton — shared across lifespan and route handlers
+91 -1
View File
@@ -185,9 +185,11 @@ def apply_dsmr_subscription(settings: "Settings | None" = None) -> None:
# Every source owns a distinct MQTT client, so equal topics from
# different sources/brokers are dispatchable. A telegram and tariff
# topic on the *same* client would overwrite one handler, however.
rejected_source_ids: set[int] = set()
for source_id, snapshot in list(desired.items()):
if snapshot.tariff_topic and snapshot.topic == snapshot.tariff_topic:
logger.error("DSMR source_id=%s rejected: telegram/tariff topic collision", source_id)
rejected_source_ids.add(source_id)
desired.pop(source_id)
with _subscription_lock:
@@ -203,6 +205,19 @@ def apply_dsmr_subscription(settings: "Settings | None" = None) -> None:
_subscription_tokens.pop(source_id, None)
set_current_tariff(source_id, None)
# A disabled source has no installed MQTT owner. Persist that fact
# after invalidating its callback token, so a retained callback cannot
# revive an earlier online state while teardown is in progress.
for source_id in stale_source_ids:
_mark_disabled_source_inactive(source_id)
# A topic collision is a configuration error for an enabled source,
# not a disabled-state transition. It must therefore replace any
# earlier online state even when this process started without a
# matching runtime subscription to tear down.
for source_id in rejected_source_ids:
_mark_rejected_source_error(source_id)
for source_id in stale_source_ids:
mqtt_manager.remove_source(source_id)
@@ -250,6 +265,9 @@ def apply_dsmr_subscription(settings: "Settings | None" = None) -> None:
tls_enabled=snapshot.tls_enabled,
subscriptions=handlers,
base_client_id=base_client_id,
state_handler=lambda state, captured=snapshot, captured_token=token: (
handle_captured_source_state(captured, captured_token, state)
),
)
if not applied:
with _subscription_lock:
@@ -293,6 +311,72 @@ def handle_captured_tariff_message(
handle_tariff_message(payload_bytes, snapshot.source_id)
def handle_captured_source_state(snapshot: DsmrSourceSnapshot, token: object, state: str) -> None:
"""Persist one active generation's connection health in a short DB session."""
with _subscription_lock:
if _subscription_tokens.get(snapshot.source_id) is not token:
return
session_local = get_session_local()
session = session_local()
try:
source = session.get(MeterSource, snapshot.source_id)
if source is None or not source.enabled or source.kind != "dsmr_mqtt":
return
source.status = state
source.last_error = "MQTT connection failed." if state == "error" else None
source.updated_at = datetime.now(timezone.utc)
session.commit()
except Exception:
session.rollback()
logger.exception("DSMR source health update failed for source_id=%s", snapshot.source_id)
finally:
session.close()
def _mark_disabled_source_inactive(source_id: int) -> None:
"""Clear an obsolete online health state for a disabled DSMR source.
The caller has already invalidated the source's generation token. This
helper deliberately opens its own short session so reconcile never shares
a callback-thread transaction. Deleted sources simply have no row left
to update.
"""
session_local = get_session_local()
session = session_local()
try:
source = session.get(MeterSource, source_id)
if source is None or source.enabled or source.kind != "dsmr_mqtt":
return
source.status = "unknown"
source.last_error = None
source.updated_at = datetime.now(timezone.utc)
session.commit()
except Exception:
session.rollback()
logger.exception("DSMR disabled source health update failed for source_id=%s", source_id)
finally:
session.close()
def _mark_rejected_source_error(source_id: int) -> None:
"""Persist a non-sensitive error for an enabled source rejected by reconcile."""
session_local = get_session_local()
session = session_local()
try:
source = session.get(MeterSource, source_id)
if source is None or not source.enabled or source.kind != "dsmr_mqtt":
return
source.status = "error"
source.last_error = "DSMR source configuration invalid."
source.updated_at = datetime.now(timezone.utc)
session.commit()
except Exception:
session.rollback()
logger.exception("DSMR rejected source health update failed for source_id=%s", source_id)
finally:
session.close()
def _handle_message_inner(payload_bytes: bytes, snapshot: DsmrSourceSnapshot) -> None:
try:
data = json.loads(payload_bytes)
@@ -331,7 +415,13 @@ def _handle_message_inner(payload_bytes: bytes, snapshot: DsmrSourceSnapshot) ->
payload=data,
)
)
session.commit()
source = session.get(MeterSource, snapshot.source_id)
if source is not None and source.enabled and source.kind == "dsmr_mqtt":
source.status = "online"
source.last_seen_at = datetime.now(timezone.utc)
source.last_error = None
source.updated_at = datetime.now(timezone.utc)
session.commit()
except sqlalchemy.exc.IntegrityError:
session.rollback()
except Exception: