feat!: Python client Protocol v2 rewrite with async Dongle surface
The 1.0 Python client, mirroring the Rust client 1.0 API and the DongLoRa Protocol v2 wire format. * Module split: monolithic protocol.py / client.py replaced with dongle.py (high-level async Dongle), session.py (reader task + tag routing), frame.py (streaming FrameDecoder + encode_frame), commands.py / events.py / errors.py / modulation.py / info.py / crc.py / chip_id (re-export) matching the donglora-protocol Rust crate. * Async API throughout. Serial I/O via asyncio-serial; mux connections via asyncio streams. The blocking client surface is gone. * thiserror-equivalent error hierarchy (DongloraError base with Timeout, TransportClosed, ReaderExited, BadFrame, ErrClientError variants mirroring the Rust ClientError). * Tag-aware dispatch with concurrent in-flight commands: TagAllocator hands out 16-bit device tags, Session routes each response back to the originating coroutine. * Auto-recovery from ERR(ENOTCONFIGURED): the Dongle caches the last SET_CONFIG and silently re-applies on timeout. * 500 ms background keepalive task per PROTOCOL.md §3.4. * Tests rewritten: test_dongle, test_session, test_frame, test_events, test_modulation, test_crc, test_ergonomics; old test_client / test_protocol removed. * Sticky mux reconnect (from the prior fix commit) is preserved: once a mux socket is in use, reconnects commit to it instead of falling back to USB.
This commit is contained in:
25 files changed
+3579
-1010
No files matched your search
@@ -0,0 +1,265 @@
|
||||
"""Event payload tests: Info, SetConfigResult, TxDone, RxEvent, ERR."""
|
||||
|
||||
import pytest
|
||||
|
||||
from donglora.commands import TYPE_PING, TYPE_SET_CONFIG
|
||||
from donglora.errors import ErrorCode
|
||||
from donglora.events import (
|
||||
Owner,
|
||||
RxEvent,
|
||||
RxOrigin,
|
||||
SetConfigResult,
|
||||
SetConfigResultCode,
|
||||
TxDone,
|
||||
TxResult,
|
||||
decode_err_payload,
|
||||
encode_err_payload,
|
||||
parse_ok_payload,
|
||||
)
|
||||
from donglora.info import Capability, Info, RadioChipId
|
||||
from donglora.modulation import LoRaConfig
|
||||
|
||||
# ── SetConfigResult ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_set_config_result_c23_bytes():
|
||||
# PROTOCOL.md §C.2.3: OK in reply to SET_CONFIG, LoRa applied.
|
||||
r = SetConfigResult(
|
||||
result=SetConfigResultCode.APPLIED,
|
||||
owner=Owner.MINE,
|
||||
current=LoRaConfig.default(),
|
||||
)
|
||||
expected = bytes(
|
||||
[
|
||||
0x00,
|
||||
0x01, # result=APPLIED, owner=MINE
|
||||
0x01, # modulation_id = LoRa
|
||||
0xA0,
|
||||
0x27,
|
||||
0xBE,
|
||||
0x33, # freq
|
||||
0x07,
|
||||
0x07,
|
||||
0x00, # sf / bw / cr
|
||||
0x08,
|
||||
0x00, # preamble_len
|
||||
0x24,
|
||||
0x14, # sync_word
|
||||
0x0E, # tx_power
|
||||
0x00,
|
||||
0x01,
|
||||
0x00, # header / crc / iq
|
||||
],
|
||||
)
|
||||
assert r.encode() == expected
|
||||
assert SetConfigResult.decode(expected) == r
|
||||
|
||||
|
||||
# ── TxDone ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tx_done_transmitted_airtime():
|
||||
td = TxDone(result=TxResult.TRANSMITTED, airtime_us=30_976)
|
||||
assert td.encode() == bytes([0x00, 0x00, 0x79, 0x00, 0x00])
|
||||
assert TxDone.decode(td.encode()) == td
|
||||
|
||||
|
||||
def test_tx_done_channel_busy():
|
||||
td = TxDone(result=TxResult.CHANNEL_BUSY, airtime_us=0)
|
||||
assert TxDone.decode(td.encode()) == td
|
||||
|
||||
|
||||
# ── RxEvent ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rx_event_c26_bytes():
|
||||
rx = RxEvent(
|
||||
rssi_dbm=-73.5,
|
||||
snr_db=9.5,
|
||||
freq_err_hz=-125,
|
||||
timestamp_us=42_000_000,
|
||||
crc_valid=True,
|
||||
packets_dropped=0,
|
||||
origin=RxOrigin.OTA,
|
||||
data=bytes([0x01, 0x02, 0x03, 0x04]),
|
||||
)
|
||||
# PROTOCOL.md §C.2.6 payload (strip frame header + CRC).
|
||||
expected = bytes(
|
||||
[
|
||||
0x21,
|
||||
0xFD, # rssi = -735 LE
|
||||
0x5F,
|
||||
0x00, # snr = 95 LE
|
||||
0x83,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xFF, # freq_err = -125 LE
|
||||
0x80,
|
||||
0xDE,
|
||||
0x80,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, # ts = 42_000_000 LE
|
||||
0x01, # crc_valid
|
||||
0x00,
|
||||
0x00, # packets_dropped
|
||||
0x00, # origin OTA
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
],
|
||||
)
|
||||
assert rx.encode() == expected
|
||||
decoded = RxEvent.decode(expected)
|
||||
assert decoded.rssi_dbm == pytest.approx(-73.5)
|
||||
assert decoded.snr_db == pytest.approx(9.5)
|
||||
assert decoded.data == bytes([0x01, 0x02, 0x03, 0x04])
|
||||
|
||||
|
||||
def test_rx_event_rejects_invalid_crc_byte():
|
||||
buf = bytearray(
|
||||
RxEvent(
|
||||
rssi_dbm=0.0,
|
||||
snr_db=0.0,
|
||||
freq_err_hz=0,
|
||||
timestamp_us=0,
|
||||
crc_valid=True,
|
||||
packets_dropped=0,
|
||||
origin=RxOrigin.OTA,
|
||||
data=b"",
|
||||
).encode(),
|
||||
)
|
||||
buf[16] = 2
|
||||
with pytest.raises(ValueError):
|
||||
RxEvent.decode(bytes(buf))
|
||||
|
||||
|
||||
# ── ERR payload ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_err_payload_roundtrip():
|
||||
wire = encode_err_payload(ErrorCode.ENOT_CONFIGURED)
|
||||
assert wire == bytes([0x03, 0x00])
|
||||
assert decode_err_payload(wire) == ErrorCode.ENOT_CONFIGURED
|
||||
|
||||
|
||||
def test_err_payload_preserves_unknown_code():
|
||||
wire = encode_err_payload(0xABCD)
|
||||
assert decode_err_payload(wire) == 0xABCD
|
||||
|
||||
|
||||
# ── OK payload context dispatch ───────────────────────────────
|
||||
|
||||
|
||||
def test_parse_ok_for_ping_requires_empty():
|
||||
assert parse_ok_payload(TYPE_PING, b"") is None
|
||||
with pytest.raises(ValueError):
|
||||
parse_ok_payload(TYPE_PING, b"\x00")
|
||||
|
||||
|
||||
def test_parse_ok_for_set_config_returns_result():
|
||||
r = SetConfigResult(
|
||||
result=SetConfigResultCode.APPLIED,
|
||||
owner=Owner.MINE,
|
||||
current=LoRaConfig.default(),
|
||||
)
|
||||
parsed = parse_ok_payload(TYPE_SET_CONFIG, r.encode())
|
||||
assert parsed == r
|
||||
|
||||
|
||||
# ── Info ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sample_info() -> Info:
|
||||
return Info(
|
||||
proto_major=1,
|
||||
proto_minor=0,
|
||||
fw_major=0,
|
||||
fw_minor=1,
|
||||
fw_patch=0,
|
||||
radio_chip_id=RadioChipId.SX1262,
|
||||
capability_bitmap=Capability.LORA | Capability.FSK | Capability.CAD_BEFORE_TX,
|
||||
supported_sf_bitmap=0x1FE0,
|
||||
supported_bw_bitmap=0x03FF,
|
||||
max_payload_bytes=255,
|
||||
rx_queue_capacity=64,
|
||||
tx_queue_capacity=16,
|
||||
freq_min_hz=150_000_000,
|
||||
freq_max_hz=960_000_000,
|
||||
tx_power_min_dbm=-9,
|
||||
tx_power_max_dbm=22,
|
||||
mcu_uid=bytes([0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]),
|
||||
radio_uid=b"",
|
||||
)
|
||||
|
||||
|
||||
def test_info_appendix_c22_bytes():
|
||||
info = _sample_info()
|
||||
expected = bytes(
|
||||
[
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x02,
|
||||
0x00, # chip id
|
||||
0x03,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, # cap bitmap
|
||||
0xE0,
|
||||
0x1F, # supported SF
|
||||
0xFF,
|
||||
0x03, # supported BW
|
||||
0xFF,
|
||||
0x00, # max payload 255
|
||||
0x40,
|
||||
0x00, # rx q 64
|
||||
0x10,
|
||||
0x00, # tx q 16
|
||||
0x80,
|
||||
0xD1,
|
||||
0xF0,
|
||||
0x08, # freq min
|
||||
0x00,
|
||||
0x70,
|
||||
0x38,
|
||||
0x39, # freq max
|
||||
0xF7,
|
||||
0x16, # tx power min/max
|
||||
0x08, # mcu_uid_len
|
||||
0xDE,
|
||||
0xAD,
|
||||
0xBE,
|
||||
0xEF,
|
||||
0x01,
|
||||
0x23,
|
||||
0x45,
|
||||
0x67,
|
||||
0x00, # radio_uid_len
|
||||
],
|
||||
)
|
||||
assert info.encode() == expected
|
||||
assert Info.decode(expected) == info
|
||||
|
||||
|
||||
def test_info_tolerates_trailing_bytes():
|
||||
info = _sample_info()
|
||||
wire = info.encode() + b"\xaa" * 10
|
||||
assert Info.decode(wire) == info
|
||||
|
||||
|
||||
def test_info_supports():
|
||||
info = _sample_info()
|
||||
assert info.supports(Capability.LORA)
|
||||
assert info.supports(Capability.LORA | Capability.FSK)
|
||||
assert not info.supports(Capability.MULTI_CLIENT)
|
||||
assert info.chip == RadioChipId.SX1262
|
||||
Reference in new issue
Block a user