Source code for pycsamt.iot.protocols.base

"""Telemetry transport base classes and the dry-run recorder.

:class:`BaseTelemetryClient` defines the transport interface shared by
every concrete client (MQTT, HTTP, file, serial, websocket): ``connect``,
``disconnect``, ``send``, ``receive``, ``subscribe``, ``listen``,
``flush``, and ``healthcheck``. Subclasses implement the ``_transport_*``
hooks; the base handles validation, dry-run recording, auto-connect, and
error wrapping.

Every client supports ``dry_run=True``, in which packets are recorded to
``client.sent`` and no network/hardware access occurs. This keeps demos,
tests, and documentation fully offline and makes optional dependencies
(paho-mqtt, pyserial, websocket-client) truly optional — they are only
imported when a real connection is opened.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable

from ..core import TelemetryPacket

__all__ = [
    "IoTProtocol",
    "TelemetryAck",
    "TelemetryError",
    "BaseTelemetryClient",
    "TelemetryClient",
]


[docs] class IoTProtocol(str, Enum): """Supported telemetry protocol identifiers.""" MQTT = "mqtt" HTTP = "http" LORA = "lora" SERIAL = "serial" FILE = "file" WEBSOCKET = "websocket"
[docs] class TelemetryError(RuntimeError): """Raised when a telemetry transport operation fails."""
[docs] @dataclass class TelemetryAck: """Acknowledgement returned by a telemetry client.""" ok: bool protocol: str packet_id: str detail: str = ""
def _coerce_packet(packet: Any) -> TelemetryPacket: if isinstance(packet, TelemetryPacket): packet.validate() return packet if isinstance(packet, dict): return TelemetryPacket(**packet) raise TypeError("packet must be a TelemetryPacket or mapping.")
[docs] class BaseTelemetryClient: """Common behaviour for telemetry transports. Parameters ---------- endpoint : str, optional Transport-specific destination (broker URL, file path, URL, serial port, ...). protocol : IoTProtocol The protocol this client implements. dry_run : bool When ``True``, packets are recorded to :attr:`sent` and no real transport is opened. **options Transport-specific options (credentials, timeouts, TLS, ...). """ protocol: IoTProtocol = IoTProtocol.FILE def __init__( self, endpoint: str | None = None, *, protocol: IoTProtocol | None = None, dry_run: bool = False, **options: Any, ) -> None: if protocol is not None: self.protocol = ( protocol if isinstance(protocol, IoTProtocol) else IoTProtocol(str(protocol)) ) self.endpoint = endpoint self.dry_run = bool(dry_run) self.options: dict[str, Any] = dict(options) self.sent: list[TelemetryPacket] = [] self.subscriptions: list[str] = [] self.connected = False self._handle: Any = None # -- context management ------------------------------------------------ def __enter__(self) -> BaseTelemetryClient: self.connect() return self def __exit__(self, *exc: Any) -> None: self.disconnect() # -- lifecycle ---------------------------------------------------------
[docs] def connect(self) -> None: """Open the transport (no-op in dry-run mode).""" if self.connected: return if not self.dry_run: try: self._connect() except TelemetryError: raise except Exception as exc: # noqa: BLE001 - normalise transport errs raise TelemetryError( f"{self.protocol.value} connect failed: {exc}" ) from exc self.connected = True
[docs] def disconnect(self) -> None: """Close the transport (no-op in dry-run mode).""" if not self.connected: return if not self.dry_run: try: self._disconnect() except Exception: # noqa: BLE001 - disconnect is best effort pass self.connected = False
# -- messaging ---------------------------------------------------------
[docs] def send(self, packet: Any) -> TelemetryAck: """Send *packet*, recording it in dry-run mode.""" pkt = _coerce_packet(packet) packet_id = self._packet_id(pkt) if self.dry_run: self.sent.append(pkt) return TelemetryAck( ok=True, protocol=self.protocol.value, packet_id=packet_id, detail="dry-run packet recorded", ) if not self.connected: self.connect() try: detail = self._transport_send(pkt) except (TelemetryError, NotImplementedError): # NotImplementedError marks an unimplemented transport (e.g. the # generic recorder); keep it distinct from real transport errors. raise except Exception as exc: # noqa: BLE001 - normalise transport errs raise TelemetryError( f"{self.protocol.value} send failed: {exc}" ) from exc self.sent.append(pkt) return TelemetryAck( ok=True, protocol=self.protocol.value, packet_id=packet_id, detail=detail or "sent", )
[docs] def receive( self, *, timeout: float | None = None ) -> dict[str, Any] | None: """Receive one payload, or ``None`` in dry-run/unsupported mode.""" if self.dry_run: return None if not self.connected: self.connect() return self._transport_receive(timeout=timeout)
[docs] def subscribe(self, topic: str) -> None: """Register interest in *topic* (transport permitting).""" topic = str(topic).strip() if not topic: raise ValueError("topic cannot be empty.") if topic not in self.subscriptions: self.subscriptions.append(topic) if not self.dry_run: if not self.connected: self.connect() self._transport_subscribe(topic)
[docs] def listen( self, callback: Callable[[dict[str, Any]], None], *, max_messages: int | None = None, timeout: float | None = None, ) -> int: """Poll :meth:`receive`, invoking *callback* per payload. Returns the number of payloads dispatched. In dry-run mode this returns ``0`` immediately. """ if self.dry_run: return 0 n = 0 while max_messages is None or n < max_messages: payload = self.receive(timeout=timeout) if payload is None: break callback(payload) n += 1 return n
[docs] def flush(self) -> int: """Flush any buffered packets; returns count flushed (default 0).""" return 0
[docs] def healthcheck(self) -> bool: """Return whether the transport is reachable.""" if self.dry_run: return True try: self.connect() return self._transport_healthcheck() except Exception: # noqa: BLE001 - health probe must not raise return False
# -- helpers ----------------------------------------------------------- @staticmethod def _packet_id(packet: TelemetryPacket) -> str: return f"{packet.device_id}:{packet.timestamp:g}:{packet.topic}" @staticmethod def _payload_bytes(packet: TelemetryPacket) -> bytes: import json return json.dumps(packet.as_dict(), default=str).encode("utf-8") def _require_endpoint(self) -> str: if not self.endpoint: raise TelemetryError( f"{self.protocol.value} client requires an endpoint." ) return str(self.endpoint) # -- transport hooks (overridden by concrete clients) ------------------ def _connect(self) -> None: # pragma: no cover - trivial default return None def _disconnect(self) -> None: # pragma: no cover - trivial default return None def _transport_send(self, packet: TelemetryPacket) -> str: raise NotImplementedError( f"{self.protocol.value} transport does not implement send()." ) def _transport_receive( self, *, timeout: float | None = None ) -> dict[str, Any] | None: raise TelemetryError( f"{self.protocol.value} transport does not support receive()." ) def _transport_subscribe(self, topic: str) -> None: raise TelemetryError( f"{self.protocol.value} transport does not support subscribe()." ) def _transport_healthcheck(self) -> bool: # pragma: no cover - default return self.connected
[docs] class TelemetryClient(BaseTelemetryClient): """Generic recorder used for dry-run simulation and unmapped protocols. Retains the historical dry-run behaviour: with ``dry_run=True`` (default) packets are recorded to :attr:`sent`. With ``dry_run=False`` it has no real transport and raises :class:`NotImplementedError` on send, since a concrete client should be used instead. """ def __init__( self, endpoint: str | None = None, *, protocol: IoTProtocol | str = IoTProtocol.FILE, dry_run: bool = True, **options: Any, ) -> None: super().__init__( endpoint, protocol=protocol, dry_run=dry_run, **options ) def _transport_send(self, packet: TelemetryPacket) -> str: raise NotImplementedError( f"{self.protocol.value} transport is not implemented; use a " "concrete client such as FileTelemetryClient or " "HTTPTelemetryClient." )