Source code for pycsamt.iot.protocols.websocket
"""WebSocket telemetry transport (optional dependency: ``websocket-client``).
:class:`WebSocketTelemetryClient` sends JSON frames to a ``ws://`` or
``wss://`` endpoint and receives them back. The ``websocket-client``
package is imported lazily.
"""
from __future__ import annotations
import json
from typing import Any
from ..core import TelemetryPacket
from .base import (
BaseTelemetryClient,
IoTProtocol,
TelemetryError,
)
__all__ = ["WebSocketTelemetryClient"]
def _import_websocket():
try:
import websocket # type: ignore # from the 'websocket-client' package
except Exception as exc: # noqa: BLE001
raise TelemetryError(
"WebSocket transport requires the 'websocket-client' package. "
"Install it with `pip install websocket-client`."
) from exc
return websocket
[docs]
class WebSocketTelemetryClient(BaseTelemetryClient):
"""Send/receive telemetry over a WebSocket connection."""
protocol = IoTProtocol.WEBSOCKET
def __init__(
self,
endpoint: str | None = None,
*,
dry_run: bool = False,
timeout: float = 10.0,
**options: Any,
) -> None:
super().__init__(
endpoint, dry_run=dry_run, timeout=timeout, **options
)
def _connect(self) -> None:
websocket = _import_websocket()
url = self._require_endpoint()
if not str(url).lower().startswith(("ws://", "wss://")):
raise TelemetryError(
f"WebSocket endpoint must start with ws:// or wss:// (got {url!r})."
)
self._handle = websocket.create_connection(
url, timeout=float(self.options.get("timeout", 10.0))
)
def _disconnect(self) -> None:
if self._handle is not None:
try:
self._handle.close()
finally:
self._handle = None
def _transport_send(self, packet: TelemetryPacket) -> str:
if self._handle is None:
raise TelemetryError("WebSocket client is not connected.")
self._handle.send(json.dumps(packet.as_dict(), default=str))
return f"sent {packet.topic} over websocket"
def _transport_receive(
self, *, timeout: float | None = None
) -> dict[str, Any] | None:
if self._handle is None:
raise TelemetryError("WebSocket client is not connected.")
try:
message = self._handle.recv()
except Exception: # noqa: BLE001 - treat recv errors as no message
return None
if not message:
return None
try:
return json.loads(message)
except json.JSONDecodeError:
return {"raw": message}
def _transport_healthcheck(self) -> bool:
return self._handle is not None and getattr(
self._handle, "connected", False
)