"""$SYS broker telemetry (spec sections 33, 55). The dynsec channel's MQTT connection subscribes to $SYS/# and forwards every message here (see DynsecClient.set_event_sink). Values are parsed ONCE at ingest; snapshot() returns the latest known state. The broker publishes $SYS on the configured sys_interval (10 s in the bootstrap config), so a fresh backend needs a few seconds before the first snapshot is populated — every field is therefore Optional and the `available` flag says whether ANY $SYS message has been seen. """ from __future__ import annotations import threading from datetime import UTC, datetime from typing import Any, Callable SYS_PREFIX = "$SYS/broker/" _lock = threading.Lock() _values: dict[str, Any] = {} _last_update: datetime | None = None def _as_int(payload: str) -> object: try: return int(payload) except ValueError: return payload def _as_float(payload: str) -> object: try: return float(payload) except ValueError: return payload # $SYS topic -> (snapshot field, parser). Field names mirror # BrokerStatusOut (schemas.py). NOTE: "retained messages/count" # genuinely contains a space in the Mosquitto topic name. _TOPIC_FIELDS: dict[str, tuple[str, Callable[[str], object]]] = { f"{SYS_PREFIX}version": ("version", str), f"{SYS_PREFIX}uptime": ("uptime", str), f"{SYS_PREFIX}timestamp": ("build_timestamp", str), # clients/disconnected = persistent clients currently offline, # i.e. the "stored sessions" of the dashboard (spec section 33). f"{SYS_PREFIX}clients/connected": ("clients_connected", _as_int), f"{SYS_PREFIX}clients/disconnected": ("clients_disconnected", _as_int), f"{SYS_PREFIX}clients/maximum": ("clients_maximum", _as_int), f"{SYS_PREFIX}clients/total": ("clients_total", _as_int), f"{SYS_PREFIX}clients/expired": ("clients_expired", _as_int), f"{SYS_PREFIX}messages/received": ("messages_received", _as_int), f"{SYS_PREFIX}messages/sent": ("messages_sent", _as_int), f"{SYS_PREFIX}messages/stored": ("messages_stored", _as_int), f"{SYS_PREFIX}messages/dropped": ("messages_dropped", _as_int), f"{SYS_PREFIX}publish/messages/received": ("publish_received", _as_int), f"{SYS_PREFIX}publish/messages/sent": ("publish_sent", _as_int), f"{SYS_PREFIX}publish/messages/dropped": ("publish_dropped", _as_int), f"{SYS_PREFIX}bytes/received": ("bytes_received", _as_int), f"{SYS_PREFIX}bytes/sent": ("bytes_sent", _as_int), f"{SYS_PREFIX}subscriptions/count": ("subscriptions_count", _as_int), f"{SYS_PREFIX}retained messages/count": ("retained_count", _as_int), # load/* are per-second averages over the last minute ("x.xx"). f"{SYS_PREFIX}load/messages/received/1min": ( "load_messages_received_1min", _as_float, ), f"{SYS_PREFIX}load/messages/sent/1min": ( "load_messages_sent_1min", _as_float, ), f"{SYS_PREFIX}load/publish/received/1min": ( "load_publish_received_1min", _as_float, ), f"{SYS_PREFIX}load/publish/sent/1min": ( "load_publish_sent_1min", _as_float, ), f"{SYS_PREFIX}load/bytes/received/1min": ( "load_bytes_received_1min", _as_float, ), f"{SYS_PREFIX}load/bytes/sent/1min": ( "load_bytes_sent_1min", _as_float, ), } _FIELDS = [field for field, _ in _TOPIC_FIELDS.values()] def handle_sys_message(topic: str, payload: bytes) -> None: """Ingest one $SYS message (wired as the dynsec client's event sink).""" entry = _TOPIC_FIELDS.get(topic) if entry is None: return # unknown $SYS topic — deliberately ignored field, parser = entry text = payload.decode("utf-8", errors="replace").strip() global _last_update with _lock: _values[field] = parser(text) _last_update = datetime.now(UTC) def reset() -> None: """Clear all telemetry (used by tests).""" global _last_update with _lock: _values.clear() _last_update = None def snapshot() -> dict[str, Any]: """Latest known $SYS state; every field None until first publish.""" with _lock: values = dict(_values) last_update = _last_update snap: dict[str, Any] = {field: values.get(field) for field in _FIELDS} snap["available"] = last_update is not None snap["last_update"] = last_update return snap