feat!: v0.2.0 — module split, Rust parity, PyPI-ready
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

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>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-08 13:33:37 -06:00
1 parent f0033466a3
commit 7a9e67ac1c
19 files changed
+1787 -395

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
"""Tests for donglora.client — send/recv/drain_rx/validate."""
from __future__ import annotations
import struct
from io import BytesIO
from typing import Any
from donglora.client import _rx_queue, drain_rx, recv, send, validate
from donglora.codec import cobs_encode
def _make_mock(responses: list[bytes]) -> Any:
"""Create a mock transport that returns pre-encoded COBS frames."""
wire = b""
for resp in responses:
wire += cobs_encode(resp)
class Mock:
def __init__(self) -> None:
self._stream = BytesIO(wire)
self.timeout = 2.0
self._written: list[bytes] = []
def read(self, n: int = 1) -> bytes:
return self._stream.read(n)
def write(self, data: bytes) -> int:
self._written.append(data)
return len(data)
def flush(self) -> None:
pass
return Mock()
def _pong() -> bytes:
return bytes([0])
def _ok() -> bytes:
return bytes([4])
def _rx_packet(payload: bytes, rssi: int = -80, snr: int = 10) -> bytes:
return (
bytes([2])
+ struct.pack("<h", rssi)
+ struct.pack("<h", snr)
+ struct.pack("<H", len(payload))
+ payload
)
class TestSend:
def setup_method(self) -> None:
_rx_queue.clear()
def test_basic_command(self) -> None:
mock = _make_mock([_pong()])
resp = send(mock, "Ping")
assert resp["type"] == "Pong"
def test_buffers_rx_packets(self) -> None:
mock = _make_mock([_rx_packet(b"pkt1"), _rx_packet(b"pkt2"), _ok()])
resp = send(mock, "StartRx")
assert resp["type"] == "Ok"
assert len(_rx_queue) == 2
def test_timeout(self) -> None:
mock = _make_mock([])
resp = send(mock, "Ping")
assert resp["type"] == "Timeout"
class TestRecv:
def setup_method(self) -> None:
_rx_queue.clear()
def test_from_buffer(self) -> None:
_rx_queue.append({"type": "RxPacket", "rssi": -80, "snr": 10, "payload": b"buf"})
mock = _make_mock([])
pkt = recv(mock)
assert pkt is not None
assert pkt["payload"] == b"buf"
def test_from_wire(self) -> None:
mock = _make_mock([_rx_packet(b"wire")])
pkt = recv(mock)
assert pkt is not None
assert pkt["payload"] == b"wire"
def test_timeout(self) -> None:
mock = _make_mock([])
assert recv(mock) is None
class TestDrainRx:
def setup_method(self) -> None:
_rx_queue.clear()
def test_drains_buffer_and_wire(self) -> None:
_rx_queue.append({"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b"buf"})
mock = _make_mock([_rx_packet(b"wire")])
packets = drain_rx(mock)
assert len(packets) == 2
assert packets[0]["payload"] == b"buf"
assert packets[1]["payload"] == b"wire"
assert len(_rx_queue) == 0
class TestValidate:
def setup_method(self) -> None:
_rx_queue.clear()
def test_success(self) -> None:
mock = _make_mock([_pong()])
validate(mock)
def test_failure(self) -> None:
mock = _make_mock([_ok()])
try:
validate(mock)
assert False, "should have raised" # noqa: B011
except ConnectionError:
pass
def test_timeout(self) -> None:
mock = _make_mock([])
try:
validate(mock)
assert False, "should have raised" # noqa: B011
except ConnectionError:
pass
+75
View File
@@ -0,0 +1,75 @@
"""Tests for donglora.codec — COBS framing."""
from __future__ import annotations
from io import BytesIO
from cobs import cobs
from donglora.codec import cobs_encode, read_frame
class TestCobsEncode:
def test_basic(self) -> None:
data = b"hello"
encoded = cobs_encode(data)
assert encoded[-1:] == b"\x00"
assert cobs.decode(encoded[:-1]) == data
def test_empty(self) -> None:
encoded = cobs_encode(b"")
assert encoded[-1:] == b"\x00"
assert cobs.decode(encoded[:-1]) == b""
def test_zeros(self) -> None:
data = b"\x00\x00\x00"
encoded = cobs_encode(data)
assert b"\x00" not in encoded[:-1]
assert cobs.decode(encoded[:-1]) == data
def test_roundtrip(self) -> None:
for length in [0, 1, 10, 100, 254, 255, 256]:
data = bytes(range(length % 256)) * (length // 256 + 1)
data = data[:length]
encoded = cobs_encode(data)
decoded = cobs.decode(encoded[:-1])
assert decoded == data
class _MockSerial:
"""Minimal mock that behaves like serial.Serial for read_frame."""
def __init__(self, data: bytes) -> None:
self._stream = BytesIO(data)
def read(self, n: int = 1) -> bytes:
return self._stream.read(n)
class TestReadFrame:
def test_basic(self) -> None:
data = b"hello"
frame = cobs_encode(data)
result = read_frame(_MockSerial(frame))
assert result == data
def test_timeout(self) -> None:
result = read_frame(_MockSerial(b""))
assert result is None
def test_empty_frame(self) -> None:
result = read_frame(_MockSerial(b"\x00"))
assert result is None
def test_corrupt_frame(self) -> None:
result = read_frame(_MockSerial(b"\xff\x00"))
# cobs.decode of [0xff] may or may not raise — just verify we get bytes or None
assert result is None or isinstance(result, bytes)
def test_multiple_frames(self) -> None:
frame1 = cobs_encode(b"first")
frame2 = cobs_encode(b"second")
mock = _MockSerial(frame1 + frame2)
assert read_frame(mock) == b"first"
assert read_frame(mock) == b"second"
assert read_frame(mock) is None
+254
View File
@@ -0,0 +1,254 @@
"""Tests for donglora.protocol — ported from client-rs/src/protocol.rs tests."""
from __future__ import annotations
import struct
import pytest
from donglora.protocol import (
DEFAULT_CONFIG,
MAX_PAYLOAD,
PREAMBLE_DEFAULT,
RADIO_CONFIG_SIZE,
TX_POWER_MAX,
Bandwidth,
ErrorCode,
RadioConfig,
decode_config,
decode_response,
encode_command,
encode_config,
)
def _make_config() -> RadioConfig:
return {
"freq_hz": 915_000_000,
"bw": Bandwidth.Khz125,
"sf": 7,
"cr": 5,
"sync_word": 0x3444,
"tx_power_dbm": 22,
"preamble_len": 16,
"cad": 1,
}
# ── RadioConfig ──────────────────────────────────────────────────
class TestRadioConfig:
def test_roundtrip(self) -> None:
cfg = _make_config()
encoded = encode_config(cfg)
assert len(encoded) == RADIO_CONFIG_SIZE
decoded = decode_config(encoded)
assert decoded == cfg
def test_default_roundtrip(self) -> None:
encoded = encode_config(DEFAULT_CONFIG)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["freq_hz"] == DEFAULT_CONFIG["freq_hz"]
assert decoded["bw"] == DEFAULT_CONFIG["bw"]
assert decoded["sf"] == DEFAULT_CONFIG["sf"]
assert decoded["cr"] == DEFAULT_CONFIG["cr"]
assert decoded["sync_word"] == DEFAULT_CONFIG["sync_word"]
assert decoded["tx_power_dbm"] == DEFAULT_CONFIG["tx_power_dbm"]
assert decoded["cad"] == DEFAULT_CONFIG["cad"]
def test_all_bandwidths(self) -> None:
for bw in Bandwidth:
cfg = {**_make_config(), "bw": bw}
encoded = encode_config(cfg)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["bw"] == bw
def test_invalid_bandwidth(self) -> None:
buf = bytearray(encode_config(_make_config()))
buf[4] = 255
assert decode_config(bytes(buf)) is None
def test_negative_power(self) -> None:
cfg = {**_make_config(), "tx_power_dbm": TX_POWER_MAX}
encoded = encode_config(cfg)
decoded = decode_config(encoded)
assert decoded is not None
assert decoded["tx_power_dbm"] == TX_POWER_MAX
def test_short_buffer(self) -> None:
assert decode_config(bytes(12)) is None
assert decode_config(b"") is None
# ── Command encoding ─────────────────────────────────────────────
class TestCommandEncoding:
def test_simple_tags(self) -> None:
assert encode_command("Ping") == bytes([0])
assert encode_command("GetConfig") == bytes([1])
assert encode_command("StartRx") == bytes([3])
assert encode_command("StopRx") == bytes([4])
assert encode_command("DisplayOn") == bytes([6])
assert encode_command("DisplayOff") == bytes([7])
assert encode_command("GetMac") == bytes([8])
def test_set_config(self) -> None:
cfg = _make_config()
cmd = encode_command("SetConfig", config=cfg)
assert cmd[0] == 2
assert len(cmd) == 1 + RADIO_CONFIG_SIZE
decoded = decode_config(cmd[1:])
assert decoded == cfg
def test_transmit_no_config(self) -> None:
cmd = encode_command("Transmit", payload=b"hello")
assert cmd[0] == 5
assert cmd[1] == 0 # has_config = false
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == 5
assert cmd[4:] == b"hello"
def test_transmit_with_config(self) -> None:
cfg = _make_config()
cmd = encode_command("Transmit", config=cfg, payload=b"test")
assert cmd[0] == 5
assert cmd[1] == 1 # has_config = true
decoded = decode_config(cmd[2:])
assert decoded == cfg
offset = 2 + RADIO_CONFIG_SIZE
length = struct.unpack_from("<H", cmd, offset)[0]
assert length == 4
assert cmd[offset + 2 :] == b"test"
def test_transmit_empty_payload(self) -> None:
cmd = encode_command("Transmit", payload=b"")
assert cmd[0] == 5
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == 0
def test_transmit_oversized_payload(self) -> None:
with pytest.raises(ValueError, match="payload too large"):
encode_command("Transmit", payload=bytes(MAX_PAYLOAD + 1))
def test_transmit_max_payload(self) -> None:
cmd = encode_command("Transmit", payload=bytes(MAX_PAYLOAD))
length = struct.unpack_from("<H", cmd, 2)[0]
assert length == MAX_PAYLOAD
def test_unknown_command(self) -> None:
with pytest.raises(ValueError, match="unknown command"):
encode_command("FakeCommand")
# ── Response decoding ────────────────────────────────────────────
class TestResponseDecoding:
def test_pong(self) -> None:
assert decode_response(bytes([0])) == {"type": "Pong"}
def test_config(self) -> None:
cfg = _make_config()
resp = decode_response(bytes([1]) + encode_config(cfg))
assert resp["type"] == "Config"
assert resp["freq_hz"] == cfg["freq_hz"]
assert resp["bw"] == cfg["bw"]
assert resp["sf"] == cfg["sf"]
assert resp["cr"] == cfg["cr"]
assert resp["sync_word"] == cfg["sync_word"]
assert resp["tx_power_dbm"] == cfg["tx_power_dbm"]
assert resp["preamble_len"] == cfg["preamble_len"]
assert resp["cad"] == cfg["cad"]
def test_rx_packet(self) -> None:
payload = b"data"
data = bytes([2])
data += struct.pack("<h", -80)
data += struct.pack("<h", 10)
data += struct.pack("<H", len(payload))
data += payload
resp = decode_response(data)
assert resp["type"] == "RxPacket"
assert resp["rssi"] == -80
assert resp["snr"] == 10
assert resp["payload"] == b"data"
def test_rx_packet_empty(self) -> None:
data = bytes([2])
data += struct.pack("<h", -120)
data += struct.pack("<h", -5)
data += struct.pack("<H", 0)
resp = decode_response(data)
assert resp["type"] == "RxPacket"
assert resp["payload"] == b""
def test_tx_done(self) -> None:
assert decode_response(bytes([3])) == {"type": "TxDone"}
def test_ok(self) -> None:
assert decode_response(bytes([4])) == {"type": "Ok"}
def test_error_codes(self) -> None:
for code in ErrorCode:
resp = decode_response(bytes([5, code]))
assert resp["type"] == "Error"
assert resp["code"] == code
def test_mac_address(self) -> None:
mac_bytes = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
resp = decode_response(bytes([6]) + mac_bytes)
assert resp["type"] == "MacAddress"
assert resp["mac"] == "AA:BB:CC:DD:EE:FF"
def test_empty(self) -> None:
assert decode_response(b"") == {"type": "Empty"}
def test_unknown_tag(self) -> None:
resp = decode_response(bytes([255]))
assert resp["type"] == "Unknown(255)"
# ── Cross-compatibility with firmware encoding ────────────────────
class TestFirmwareCompatibility:
def test_worked_example(self) -> None:
"""From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power."""
cfg: RadioConfig = {
"freq_hz": 915_000_000,
"bw": Bandwidth.Khz125,
"sf": 7,
"cr": 5,
"sync_word": 0x1424,
"tx_power_dbm": TX_POWER_MAX,
"preamble_len": PREAMBLE_DEFAULT,
"cad": 1,
}
cmd = encode_command("SetConfig", config=cfg)
expected = bytes(
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
)
assert cmd == expected
# ── Enum coverage ────────────────────────────────────────────────
class TestEnums:
def test_bandwidth_values(self) -> None:
assert Bandwidth.Khz7 == 0
assert Bandwidth.Khz500 == 9
assert len(Bandwidth) == 10
def test_error_code_values(self) -> None:
assert ErrorCode.InvalidConfig == 0
assert ErrorCode.RadioBusy == 1
assert ErrorCode.TxTimeout == 2
assert ErrorCode.CrcError == 3
assert ErrorCode.NotConfigured == 4
assert ErrorCode.NoDisplay == 5
assert len(ErrorCode) == 6