"""Topic explorer sessions (spec sections 35, 36). One MQTT v5 client per active browser stream. Sessions live only while the Server-Sent-Events connection is open; the stream generator's finally-clause stops the session, which disconnects the client — no lingering subscriptions, no manual cleanup by the operator. Why MQTT v5: the explorer must surface MQTT 5 publish properties (spec section 35), which only arrive when the client itself speaks v5. The dynsec management channel deliberately stays on its own connection. Payloads are base64 with a looks-like-UTF-8 flag: messages can be binary, and the UI offers text/hex views accordingly. """ from __future__ import annotations import base64 import json import logging import threading import time import uuid from collections import deque from dataclasses import dataclass, field from typing import Any import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from app.config import get_settings logger = logging.getLogger(__name__) _BUFFER_SIZE = 1000 # ring buffer per session _GRACE_SECONDS = 30.0 # page reload / SSE reconnect window def _payload_info(payload: bytes) -> dict[str, Any]: """base64 + heuristic UTF-8 decoding for the UI.""" info: dict[str, Any] = {"payload_b64": base64.b64encode(payload).decode()} try: info["payload_text"] = payload.decode("utf-8") except UnicodeDecodeError: info["payload_text"] = None # binary — UI shows hex return info @dataclass class _Message: index: int topic: str qos: int retain: bool received_at: float payload_b64: str payload_text: str | None properties: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: return { "index": self.index, "topic": self.topic, "qos": self.qos, "retain": self.retain, "received_at": self.received_at, "payload_b64": self.payload_b64, "payload_text": self.payload_text, "properties": self.properties, } @dataclass class ExplorerSession: id: str username: str topic_filter: str client: mqtt.Client = field(repr=False) buffer: deque[_Message] = field(default_factory=lambda: deque(maxlen=_BUFFER_SIZE)) _next_index: int = 0 _cond: threading.Condition = field(default_factory=threading.Condition) _subscribed: threading.Event = field(default_factory=threading.Event) created_at: float = field(default_factory=time.time) def _on_connect(self, client, userdata, flags, reason_code, properties) -> None: if reason_code == 0: client.subscribe(self.topic_filter, qos=0) else: logger.error("explorer session %s connect failed: %s", self.id, reason_code) def _on_subscribe(self, client, userdata, mid, reason_codes, properties) -> None: # SUBACK received: the subscription is now live on the broker. self._subscribed.set() def _on_message(self, client, userdata, msg) -> None: # MQTT v5: surface publish properties (content type, response # topic, user properties, ...). paho exposes them as msg.properties. props: dict[str, Any] | None = None raw_props = getattr(msg, "properties", None) if raw_props is not None: try: props = {} if getattr(raw_props, "ContentType", None): props["content_type"] = raw_props.ContentType if getattr(raw_props, "ResponseTopic", None): props["response_topic"] = raw_props.ResponseTopic if getattr(raw_props, "CorrelationData", None): props["correlation_data"] = raw_props.CorrelationData.decode( "utf-8", errors="replace" ) if getattr(raw_props, "UserProperty", None): props["user_properties"] = [ [k, v] for k, v in raw_props.UserProperty ] except Exception: # noqa: BLE001 - properties must never break capture props = None info = _payload_info(msg.payload) with self._cond: message = _Message( index=self._next_index, topic=msg.topic, qos=msg.qos, retain=bool(getattr(msg, "retain", False)), received_at=time.time(), payload_b64=info["payload_b64"], payload_text=info["payload_text"], properties=props, ) self._next_index += 1 self.buffer.append(message) self._cond.notify_all() class ExplorerSessions: """Thread-safe registry of live explorer sessions.""" def __init__(self) -> None: self._lock = threading.Lock() self._sessions: dict[str, ExplorerSession] = {} def start(self, username: str, topic_filter: str) -> ExplorerSession: settings = get_settings() session_id = uuid.uuid4().hex client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id=f"mqtt-explorer-{session_id[:8]}", protocol=mqtt.MQTTv5, ) client.username_pw_set( settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password ) session = ExplorerSession( id=session_id, username=username, topic_filter=topic_filter, client=client, ) client.on_connect = session._on_connect client.on_subscribe = session._on_subscribe client.on_message = session._on_message client.connect( settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30 ) client.loop_start() # Block until the broker acknowledges the subscription: without # this, a message published immediately after subscribe returns # can be missed (SUBACK arrives asynchronously on the network # loop). 5 s covers a cold broker start; on timeout the session # still proceeds — delivery may just start slightly later. if not session._subscribed.wait(timeout=5.0): logger.warning( "explorer session %s: SUBACK not received within 5 s", session_id, ) with self._lock: self._sessions[session_id] = session return session def get(self, session_id: str) -> ExplorerSession: with self._lock: session = self._sessions.get(session_id) if session is None: raise KeyError(session_id) return session def stop(self, session_id: str) -> None: with self._lock: session = self._sessions.pop(session_id, None) if session is None: return try: session.client.disconnect() except OSError: pass session.client.loop_stop() def sweep(self) -> None: """Drop sessions whose buffer has gone silent past the grace window (browser tab closed without the SSE finally firing).""" cutoff = time.time() - _GRACE_SECONDS with self._lock: stale = [ sid for sid, s in self._sessions.items() if s.buffer and s.buffer[-1].received_at < cutoff and time.time() - s.created_at > _GRACE_SECONDS ] for sid in stale: self.stop(sid) def stop_all(self) -> None: with self._lock: ids = list(self._sessions) for sid in ids: self.stop(sid) sessions = ExplorerSessions() def messages_since(session_id: str, index: int) -> list[dict[str, Any]]: """All buffered messages with index > the given one.""" session = sessions.get(session_id) with session._cond: return [m.as_dict() for m in session.buffer if m.index > index] def list_retained(max_results: int = 200, timeout: float = 3.0) -> list[dict[str, Any]]: """Enumerate retained messages (spec section 36). MQTT brokers expose no "list retained" API: the defined mechanism is to subscribe to # — the broker immediately delivers every retained message matching the filter (retain flag set on delivery) — collect briefly, then unsubscribe. $SYS is filtered out of the listing. """ settings = get_settings() collected: dict[str, _Message] = {} done = threading.Event() client_id = f"mqtt-explorer-retained-{uuid.uuid4().hex[:8]}" client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id=client_id, protocol=mqtt.MQTTv5, ) client.username_pw_set( settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password ) def _on_connect(cl, userdata, flags, reason_code, properties) -> None: if reason_code == 0: cl.subscribe("#", qos=0) else: done.set() # nothing to collect without a subscription def _on_message(cl, userdata, msg) -> None: if not getattr(msg, "retain", False): return # only retained deliveries belong in the listing if msg.topic.startswith("$SYS"): return # telemetry noise, not retained app data if len(collected) >= max_results: done.set() return info = _payload_info(msg.payload) collected[msg.topic] = _Message( index=len(collected), topic=msg.topic, qos=msg.qos, retain=True, received_at=time.time(), payload_b64=info["payload_b64"], payload_text=info["payload_text"], ) client.on_connect = _on_connect client.on_message = _on_message client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30) client.loop_start() try: done.wait(timeout) finally: try: client.disconnect() except OSError: pass client.loop_stop() return [m.as_dict() for _, m in sorted(collected.items())] def publish_message( topic: str, payload: bytes, qos: int, retain: bool, timeout: float = 10.0 ) -> None: """Publish a test message as the management identity (spec section 35). A short-lived dedicated client keeps the explorer's session clients purely read-only. """ settings = get_settings() client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id=f"mqtt-explorer-pub-{uuid.uuid4().hex[:8]}", protocol=mqtt.MQTTv5, ) client.username_pw_set( settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password ) client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30) client.loop_start() try: info = client.publish(topic, payload, qos=qos, retain=retain) info.wait_for_publish(timeout=timeout) if not info.is_published(): raise RuntimeError("publish did not complete in time") finally: try: client.disconnect() except OSError: pass client.loop_stop()