M8-R02: report DSMR MQTT source health
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user