123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""$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 |