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.
266 lines
7.1 KiB
Python
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
|