Source code for pycsamt.iot.protocols.mqtt

"""MQTT telemetry transport (optional dependency: ``paho-mqtt``).

:class:`MQTTTelemetryClient` publishes packets to their topic and can
subscribe/listen for downlink messages. ``paho-mqtt`` is imported lazily
inside :meth:`_connect`, so the subpackage — and dry-run simulations —
work without it installed.
"""

from __future__ import annotations

import json
from typing import Any
from urllib.parse import urlparse

from ..core import TelemetryPacket
from .base import (
    BaseTelemetryClient,
    IoTProtocol,
    TelemetryError,
)

__all__ = ["MQTTTelemetryClient"]


def _import_paho():
    try:
        import paho.mqtt.client as mqtt  # type: ignore
    except Exception as exc:  # noqa: BLE001
        raise TelemetryError(
            "MQTT transport requires the 'paho-mqtt' package. Install it "
            "with `pip install paho-mqtt`."
        ) from exc
    return mqtt


[docs] class MQTTTelemetryClient(BaseTelemetryClient): """Publish/subscribe telemetry over MQTT.""" protocol = IoTProtocol.MQTT def __init__( self, endpoint: str | None = None, *, dry_run: bool = False, host: str | None = None, port: int = 1883, username: str | None = None, password: str | None = None, tls: bool = False, keepalive: int = 60, client_id: str | None = None, **options: Any, ) -> None: super().__init__( endpoint, dry_run=dry_run, host=host, port=port, username=username, password=password, tls=tls, keepalive=keepalive, client_id=client_id, **options, ) self._inbox: list[dict[str, Any]] = [] def _resolve_host_port(self) -> tuple[str, int]: host = self.options.get("host") port = int(self.options.get("port", 1883)) if host: return str(host), port endpoint = self._require_endpoint() parsed = urlparse( endpoint if "://" in endpoint else f"mqtt://{endpoint}" ) if not parsed.hostname: raise TelemetryError(f"Cannot parse MQTT host from {endpoint!r}.") if parsed.scheme in {"mqtts", "ssl"}: self.options["tls"] = True return parsed.hostname, int(parsed.port or port) def _connect(self) -> None: mqtt = _import_paho() host, port = self._resolve_host_port() client = mqtt.Client(client_id=self.options.get("client_id") or "") username = self.options.get("username") if username: client.username_pw_set(username, self.options.get("password")) if self.options.get("tls"): client.tls_set() def _on_message(_client, _userdata, message): # pragma: no cover try: payload = json.loads(message.payload.decode("utf-8")) except Exception: # noqa: BLE001 payload = {"raw": message.payload} payload.setdefault("topic", message.topic) self._inbox.append(payload) client.on_message = _on_message client.connect( host, port, keepalive=int(self.options.get("keepalive", 60)) ) client.loop_start() self._handle = client def _disconnect(self) -> None: if self._handle is not None: try: self._handle.loop_stop() self._handle.disconnect() finally: self._handle = None def _transport_send(self, packet: TelemetryPacket) -> str: if self._handle is None: raise TelemetryError("MQTT client is not connected.") info = self._handle.publish( packet.topic, payload=self._payload_bytes(packet), qos=int(packet.qos), retain=bool(packet.retained), ) try: info.wait_for_publish( timeout=float(self.options.get("timeout", 10.0)) ) except Exception: # noqa: BLE001 - older paho lacks timeout arg pass return f"published to {packet.topic}" def _transport_subscribe(self, topic: str) -> None: if self._handle is None: raise TelemetryError("MQTT client is not connected.") self._handle.subscribe(topic) def _transport_receive( self, *, timeout: float | None = None ) -> dict[str, Any] | None: if self._inbox: return self._inbox.pop(0) return None def _transport_healthcheck(self) -> bool: return self._handle is not None and self._handle.is_connected()