Files
client-py/tests/test_events.py
T
swaits e9c4bb3e93 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.
2026-04-22 09:53:35 -06:00

266 lines
7.1 KiB
Python

"""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