Files
client-py/donglora/client.py
T
swaitsandClaude Opus 4.6 7a9e67ac1c
CI / lint (push) Canceled after 0s
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / publish (push) Canceled after 0s
feat!: v0.2.0 — module split, Rust parity, PyPI-ready
Split monolithic __init__.py into six modules matching the Rust client
structure: protocol.py, codec.py, discovery.py, transport.py, connect.py,
client.py. All symbols re-exported from top-level for backwards compat.

New features:
- Ping-on-connect validation (200ms handshake, matches Rust v0.2.0)
- try_connect(), connect_default(), connect_mux_auto(), validate()
- connect() commits to mux when socket exists (no USB fall-through)
- Bandwidth/ErrorCode IntEnums, RadioConfig TypedDict, py.typed marker
- MAX_PAYLOAD, RADIO_CONFIG_SIZE, tag/error constants
- Payload size validation on Transmit
- 46 pytest tests ported from Rust (protocol, codec, client, firmware compat)
- GitHub Actions CI (ruff + pytest on 3.10-3.13 + PyPI publish)
- Strict ruff linting (E, W, F, I, UP, B, C4, ARG, SIM, RUF)

Breaking: package renamed back to donglora (from donglora-python).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:33:37 -06:00

109 lines
3.4 KiB
Python

"""High-level DongLoRa client helpers.
Mirrors the Rust client's ``client.rs``. Provides :func:`send`,
:func:`recv`, :func:`drain_rx`, and :func:`validate` as module-level
functions that operate on any transport (serial port or mux connection).
"""
from __future__ import annotations
import collections
from typing import Any
from donglora.codec import cobs_encode, read_frame
from donglora.protocol import decode_response, encode_command
RX_BUFFER_CAP: int = 256
"""Maximum number of buffered RxPackets before oldest are dropped."""
MAX_UNSOLICITED_BEFORE_TIMEOUT: int = 50
"""Safety bound: max frames to read while waiting for a solicited response."""
HANDSHAKE_TIMEOUT: float = 0.2
"""Timeout (seconds) for the ping-on-connect handshake."""
_rx_queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=RX_BUFFER_CAP)
"""Buffer for RxPacket frames encountered while waiting for solicited responses."""
def validate(ser: Any) -> None:
"""Ping the device and verify the Pong response.
Uses a short timeout to fail fast on non-DongLoRa devices. Called
automatically by the ``connect`` family of functions.
Raises :class:`ConnectionError` if the device does not respond.
"""
saved = ser.timeout
ser.timeout = HANDSHAKE_TIMEOUT
try:
resp = send(ser, "Ping")
if resp["type"] != "Pong":
raise ConnectionError(f"device did not respond to ping — got {resp['type']}")
except ConnectionError:
raise
except Exception as exc:
raise ConnectionError("device did not respond to ping — not a DongLoRa device") from exc
finally:
ser.timeout = saved
def send(ser: Any, cmd: str, **kwargs: Any) -> dict[str, Any]:
"""Send a command and return the solicited response.
Any unsolicited ``RxPacket`` frames encountered while waiting are
buffered in the module-level receive queue (retrievable via
:func:`recv` / :func:`drain_rx`).
"""
ser.write(cobs_encode(encode_command(cmd, **kwargs)))
ser.flush()
for _ in range(MAX_UNSOLICITED_BEFORE_TIMEOUT):
data = read_frame(ser)
if data is None:
return {"type": "Timeout"}
resp = decode_response(data)
if resp["type"] == "RxPacket":
_rx_queue.append(resp)
continue
return resp
return {"type": "Timeout"}
def recv(ser: Any) -> dict[str, Any] | None:
"""Return the next RxPacket from the buffer or the wire.
Returns ``None`` on timeout (no packet available).
"""
if _rx_queue:
return _rx_queue.popleft()
data = read_frame(ser)
if data is None:
return None
resp = decode_response(data)
if resp["type"] == "RxPacket":
return resp
return None
def drain_rx(ser: Any) -> list[dict[str, Any]]:
"""Drain all buffered and pending RxPacket frames.
Temporarily reduces the read timeout to quickly drain any frames
still in flight, then restores the original timeout.
"""
packets: list[dict[str, Any]] = list(_rx_queue)
_rx_queue.clear()
old_timeout = ser.timeout
ser.timeout = 0.01
try:
while True:
data = read_frame(ser)
if data is None:
break
resp = decode_response(data)
if resp["type"] == "RxPacket":
packets.append(resp)
finally:
ser.timeout = old_timeout
return packets