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
@@ -1,135 +0,0 @@
|
||||
"""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
|
||||
+5
-43
@@ -1,12 +1,13 @@
|
||||
"""Tests for donglora.codec — COBS framing."""
|
||||
"""COBS helper tests — the thin re-export layer.
|
||||
|
||||
Frame-level codec tests live in ``test_frame.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from cobs import cobs
|
||||
|
||||
from donglora.codec import cobs_encode, read_frame
|
||||
from donglora.codec import cobs_encode
|
||||
|
||||
|
||||
class TestCobsEncode:
|
||||
@@ -34,42 +35,3 @@ class TestCobsEncode:
|
||||
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
|
||||
@@ -0,0 +1,23 @@
|
||||
"""CRC-16/CCITT-FALSE conformance tests."""
|
||||
|
||||
from donglora.crc import crc16
|
||||
|
||||
|
||||
def test_check_value_matches_spec():
|
||||
# PROTOCOL.md §2.2 / Appendix B.
|
||||
assert crc16(b"123456789") == 0x29B1
|
||||
|
||||
|
||||
def test_empty_input_is_initial_value():
|
||||
# No reflection, no XOR-out: empty leaves init untouched.
|
||||
assert crc16(b"") == 0xFFFF
|
||||
|
||||
|
||||
def test_single_null_byte():
|
||||
# A naive init=0 impl would yield 0; correct init=0xFFFF yields 0xE1F0.
|
||||
assert crc16(b"\x00") == 0xE1F0
|
||||
|
||||
|
||||
def test_differs_from_kermit_variant():
|
||||
# CRC-16/KERMIT of "123456789" is 0x2189.
|
||||
assert crc16(b"123456789") != 0x2189
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Dongle-level tests: keepalive thread, auto-recovery, context manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from donglora.commands import TYPE_PING, TYPE_TX
|
||||
from donglora.dongle import Dongle
|
||||
from donglora.errors import DongloraError
|
||||
from donglora.events import (
|
||||
TYPE_ERR,
|
||||
TxResult,
|
||||
)
|
||||
from donglora.frame import encode_frame
|
||||
from donglora.modulation import LoRaConfig
|
||||
from donglora.session import Session
|
||||
from tests.test_session import FakeTransport, _default_script
|
||||
|
||||
|
||||
def _make_dongle(transport: FakeTransport) -> Dongle:
|
||||
session = Session(transport)
|
||||
info = session.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session.set_config(cfg)
|
||||
return Dongle(session, info, applied_config=cfg, keepalive=False)
|
||||
|
||||
|
||||
def test_dongle_tx_returns_tx_done():
|
||||
t = FakeTransport()
|
||||
try:
|
||||
d = _make_dongle(t)
|
||||
with d:
|
||||
td = d.tx(b"hello")
|
||||
assert td.airtime_us == 30_976
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_info_property_exposes_cached_info():
|
||||
t = FakeTransport()
|
||||
try:
|
||||
with _make_dongle(t) as d:
|
||||
assert d.info.proto_major == 1
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_auto_recovers_from_not_configured():
|
||||
"""First TX returns ENOTCONFIGURED; Dongle re-applies cached config,
|
||||
then retries and succeeds.
|
||||
"""
|
||||
|
||||
class FlipScript:
|
||||
def __init__(self):
|
||||
self._first_tx = True
|
||||
|
||||
def __call__(self, type_id, tag, payload):
|
||||
if type_id == TYPE_TX and self._first_tx:
|
||||
self._first_tx = False
|
||||
return [encode_frame(TYPE_ERR, tag, b"\x03\x00")] # ENOTCONFIGURED
|
||||
return _default_script(type_id, tag, payload)
|
||||
|
||||
t = FakeTransport(script=FlipScript())
|
||||
try:
|
||||
with _make_dongle(t) as d:
|
||||
td = d.tx(b"retry-me")
|
||||
assert td.result == TxResult.TRANSMITTED
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_context_manager_closes_session():
|
||||
t = FakeTransport()
|
||||
try:
|
||||
d = _make_dongle(t)
|
||||
with d:
|
||||
pass
|
||||
# After exit, further operations raise DongloraError.
|
||||
with pytest.raises(DongloraError):
|
||||
d.tx(b"too-late")
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_close_is_idempotent():
|
||||
t = FakeTransport()
|
||||
try:
|
||||
d = _make_dongle(t)
|
||||
d.close()
|
||||
d.close() # must not raise
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_keepalive_daemon_sends_pings():
|
||||
"""With keepalive enabled, a quiet dongle emits PINGs at ~500ms cadence."""
|
||||
t = FakeTransport()
|
||||
try:
|
||||
session = Session(t)
|
||||
info = session.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session.set_config(cfg)
|
||||
# Record frames sent after setup. keepalive=True starts the
|
||||
# background thread.
|
||||
t.log.clear()
|
||||
d = Dongle(session, info, applied_config=cfg, keepalive=True)
|
||||
time.sleep(0.8) # allow at least one keepalive interval
|
||||
d.close()
|
||||
# Expect at least one PING in the log.
|
||||
from donglora.frame import decode_frame
|
||||
|
||||
ping_count = sum(1 for raw in t.log if decode_frame(raw).type_id == TYPE_PING)
|
||||
assert ping_count >= 1
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_dongle_config_property_tracks_set_config():
|
||||
t = FakeTransport()
|
||||
try:
|
||||
with _make_dongle(t) as d:
|
||||
assert d.config == LoRaConfig.default()
|
||||
custom = LoRaConfig(
|
||||
freq_hz=915_000_000,
|
||||
sf=10,
|
||||
bw=d.config.bw,
|
||||
cr=d.config.cr,
|
||||
)
|
||||
d.set_config(custom)
|
||||
assert d.config == custom
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
class _EOFTransport(FakeTransport):
|
||||
"""Like FakeTransport, but `read()` raises ConnectionError after close.
|
||||
|
||||
Matches the real-world ``MuxConnection.read`` / ``serial.Serial.read``
|
||||
behaviour when the remote hangs up, which is what the Dongle's
|
||||
transparent-reconnect path needs to see.
|
||||
"""
|
||||
|
||||
def read(self, n: int = 1) -> bytes:
|
||||
data = super().read(n)
|
||||
if not data and self._closed:
|
||||
raise ConnectionError("transport closed")
|
||||
return data
|
||||
|
||||
|
||||
def test_dongle_transparent_reconnect_on_session_death():
|
||||
"""When the session's reader dies (mux restart / transport blip),
|
||||
the Dongle transparently reopens and a subsequent ``tx()`` call
|
||||
just works. The caller never sees an exception.
|
||||
|
||||
Drives it with a reopener that returns a *fresh* transport for the
|
||||
second connect — simulating the mux coming back up under a new
|
||||
process with the original radio state intact.
|
||||
"""
|
||||
# Transport #1 — will be "killed" mid-test to simulate mux death.
|
||||
t1 = _EOFTransport()
|
||||
# Transport #2 — stands in for the replacement mux coming back.
|
||||
t2 = FakeTransport()
|
||||
|
||||
session1 = Session(t1)
|
||||
info1 = session1.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session1.set_config(cfg)
|
||||
|
||||
def reopener():
|
||||
s = Session(t2)
|
||||
i = s.get_info()
|
||||
s.set_config(cfg)
|
||||
return (s, i, cfg)
|
||||
|
||||
d = Dongle(
|
||||
session1,
|
||||
info1,
|
||||
applied_config=cfg,
|
||||
keepalive=False,
|
||||
_reopener=reopener,
|
||||
)
|
||||
try:
|
||||
# Sanity: first TX works through session #1.
|
||||
assert d.tx(b"hi").result == TxResult.TRANSMITTED
|
||||
|
||||
# Simulate mux going away. Reader thread sees ConnectionError
|
||||
# on its next read and exits.
|
||||
t1.close()
|
||||
# Give the reader a moment to notice.
|
||||
time.sleep(0.2)
|
||||
|
||||
# Second TX MUST succeed — Dongle reconnects through the
|
||||
# reopener transparently.
|
||||
assert d.tx(b"after-reconnect").result == TxResult.TRANSMITTED
|
||||
# And the cached config is preserved across reconnect.
|
||||
assert d.config == cfg
|
||||
finally:
|
||||
d.close()
|
||||
t2.close()
|
||||
|
||||
|
||||
def test_dongle_without_reopener_surfaces_dead_session():
|
||||
"""Manually-constructed Dongles (no reopener) must not swallow
|
||||
session death — the caller gets a real error."""
|
||||
t = _EOFTransport()
|
||||
try:
|
||||
session = Session(t)
|
||||
info = session.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session.set_config(cfg)
|
||||
d = Dongle(session, info, applied_config=cfg, keepalive=False) # no reopener
|
||||
t.close()
|
||||
time.sleep(0.2) # let the reader die
|
||||
with pytest.raises(DongloraError):
|
||||
d.tx(b"no-reopener")
|
||||
finally:
|
||||
t.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Sanity check that the three-line happy path works.
|
||||
|
||||
If these tests need anything beyond ``import donglora as dl`` + three
|
||||
method calls, the client lost its reason for existing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import donglora as dl
|
||||
from donglora.events import TYPE_RX, RxOrigin
|
||||
|
||||
|
||||
def test_happy_path_tx_is_three_lines():
|
||||
"""Three lines: get a Dongle (from the test harness), tx, done."""
|
||||
from donglora.dongle import Dongle
|
||||
from donglora.modulation import LoRaConfig
|
||||
from donglora.session import Session
|
||||
from tests.test_session import FakeTransport
|
||||
|
||||
t = FakeTransport()
|
||||
try:
|
||||
session = Session(t)
|
||||
info = session.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session.set_config(cfg)
|
||||
with Dongle(session, info, applied_config=cfg, keepalive=False) as d:
|
||||
td = d.tx(b"Hello")
|
||||
assert td.result.name == "TRANSMITTED"
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_rx_iterator_yields_incoming_packets():
|
||||
"""The `.rx()` iterator terminates cleanly on timeout."""
|
||||
from donglora.dongle import Dongle
|
||||
from donglora.events import RxEvent
|
||||
from donglora.modulation import LoRaConfig
|
||||
from donglora.session import Session
|
||||
from tests.test_session import FakeTransport
|
||||
|
||||
t = FakeTransport()
|
||||
try:
|
||||
session = Session(t)
|
||||
info = session.get_info()
|
||||
cfg = LoRaConfig.default()
|
||||
session.set_config(cfg)
|
||||
rx = RxEvent(
|
||||
rssi_dbm=-72.0,
|
||||
snr_db=8.0,
|
||||
freq_err_hz=0,
|
||||
timestamp_us=100,
|
||||
crc_valid=True,
|
||||
packets_dropped=0,
|
||||
origin=RxOrigin.OTA,
|
||||
data=b"ping",
|
||||
)
|
||||
with Dongle(session, info, applied_config=cfg, keepalive=False) as d:
|
||||
# Inject two RX events, then time out.
|
||||
t.push_async(TYPE_RX, 0, rx.encode())
|
||||
t.push_async(TYPE_RX, 0, rx.encode())
|
||||
received: list[bytes] = []
|
||||
for pkt in d.rx(timeout=0.5):
|
||||
received.append(pkt.data)
|
||||
if len(received) == 2:
|
||||
break
|
||||
assert received == [b"ping", b"ping"]
|
||||
finally:
|
||||
t.close()
|
||||
|
||||
|
||||
def test_public_api_names_are_stable():
|
||||
# The top-level module exposes the core happy-path surface. If any
|
||||
# of these disappear it's a breaking change we should notice.
|
||||
for name in ("connect", "Dongle", "LoRaConfig", "tx", "rx", "recv"):
|
||||
assert hasattr(dl, name), f"donglora.{name} must stay exported"
|
||||
@@ -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
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Frame codec tests — every Appendix C hex vector pinned.
|
||||
|
||||
These match the Rust crate's ``tests/vectors.rs`` byte-for-byte. If the
|
||||
client-py and firmware ever disagree, one of these tests fails first.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from donglora.commands import (
|
||||
TYPE_PING,
|
||||
TYPE_TX,
|
||||
encode_ping_payload,
|
||||
encode_tx_payload,
|
||||
)
|
||||
from donglora.events import TYPE_ERR, TYPE_OK, TYPE_TX_DONE
|
||||
from donglora.frame import (
|
||||
Frame,
|
||||
FrameCobsError,
|
||||
FrameCrcError,
|
||||
FrameTooShortError,
|
||||
decode_frame,
|
||||
encode_frame,
|
||||
read_frame,
|
||||
)
|
||||
|
||||
# ── §C.2.1 PING / OK ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_c21_ping_encodes_to_spec_bytes():
|
||||
wire = encode_frame(TYPE_PING, 0x0001, encode_ping_payload())
|
||||
assert wire == bytes([0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00])
|
||||
|
||||
|
||||
def test_c21_ok_encodes_to_spec_bytes():
|
||||
wire = encode_frame(TYPE_OK, 0x0001, b"")
|
||||
assert wire == bytes([0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00])
|
||||
|
||||
|
||||
# ── §C.2.4 TX / OK / TX_DONE ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_c24_tx_with_cad():
|
||||
wire = encode_frame(TYPE_TX, 0x0004, encode_tx_payload(b"Hello", skip_cad=False))
|
||||
assert wire == bytes(
|
||||
[0x03, 0x04, 0x04, 0x01, 0x08, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x26, 0x40, 0x00],
|
||||
)
|
||||
|
||||
|
||||
def test_c24_ok():
|
||||
wire = encode_frame(TYPE_OK, 0x0004, b"")
|
||||
assert wire == bytes([0x03, 0x80, 0x04, 0x03, 0x02, 0x3B, 0x00])
|
||||
|
||||
|
||||
def test_c24_tx_done():
|
||||
# Payload = result(0=TRANSMITTED) + airtime_us(30976 LE).
|
||||
payload = bytes([0x00, 0x00, 0x79, 0x00, 0x00])
|
||||
wire = encode_frame(TYPE_TX_DONE, 0x0004, payload)
|
||||
assert wire == bytes(
|
||||
[0x03, 0xC1, 0x04, 0x01, 0x01, 0x02, 0x79, 0x01, 0x03, 0xE3, 0xFA, 0x00],
|
||||
)
|
||||
|
||||
|
||||
# ── §C.2.5 TX skip_cad ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_c25_tx_skip_cad():
|
||||
wire = encode_frame(TYPE_TX, 0x0005, encode_tx_payload(b"URGENT", skip_cad=True))
|
||||
assert wire == bytes(
|
||||
[0x03, 0x04, 0x05, 0x0A, 0x01, 0x55, 0x52, 0x47, 0x45, 0x4E, 0x54, 0xDB, 0x1C, 0x00],
|
||||
)
|
||||
|
||||
|
||||
# ── §C.5.1 ENOTCONFIGURED ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_c51_err_notconfigured():
|
||||
# Payload = 0x0003 LE.
|
||||
wire = encode_frame(TYPE_ERR, 0x0028, bytes([0x03, 0x00]))
|
||||
assert wire == bytes([0x03, 0x81, 0x28, 0x02, 0x03, 0x03, 0x53, 0x7E, 0x00])
|
||||
|
||||
|
||||
# ── §C.6.4 Async EFRAME (tag=0) ─────────────────────────────────
|
||||
|
||||
|
||||
def test_c64_async_err_eframe():
|
||||
wire = encode_frame(TYPE_ERR, 0x0000, bytes([0x02, 0x01]))
|
||||
assert wire == bytes([0x02, 0x81, 0x01, 0x05, 0x02, 0x01, 0xCE, 0xEF, 0x00])
|
||||
|
||||
|
||||
# ── §C.8.1 COBS edge: no zeros in body ─────────────────────────
|
||||
|
||||
|
||||
def test_c81_no_zeros_in_body():
|
||||
# PING tag=0x0101 → tag bytes have no 0x00, body has no zeros.
|
||||
wire = encode_frame(TYPE_PING, 0x0101, b"")
|
||||
assert wire == bytes([0x06, 0x01, 0x01, 0x01, 0xBC, 0xD8, 0x00])
|
||||
|
||||
|
||||
# ── Decode-side round-trip ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_decode_roundtrip_empty_payload():
|
||||
wire = encode_frame(TYPE_PING, 0x0042, b"")
|
||||
frame = decode_frame(wire[:-1]) # strip sentinel
|
||||
assert frame == Frame(type_id=TYPE_PING, tag=0x0042, payload=b"")
|
||||
|
||||
|
||||
def test_decode_roundtrip_with_payload():
|
||||
payload = b"hello-world"
|
||||
wire = encode_frame(TYPE_TX, 0x0123, payload)
|
||||
frame = decode_frame(wire[:-1])
|
||||
assert frame.type_id == TYPE_TX
|
||||
assert frame.tag == 0x0123
|
||||
assert frame.payload == payload
|
||||
|
||||
|
||||
def test_decode_rejects_crc_flip():
|
||||
wire = bytearray(encode_frame(TYPE_PING, 0x0001, b""))
|
||||
# Flip one byte of the CRC region.
|
||||
wire[-3] ^= 0x01
|
||||
with pytest.raises((FrameCrcError, FrameCobsError)):
|
||||
decode_frame(bytes(wire[:-1]))
|
||||
|
||||
|
||||
def test_decode_rejects_too_short_frame():
|
||||
# COBS of a 4-byte body: [0x05, 0xAA, 0xBB, 0xCC, 0xDD]. Decodes to
|
||||
# 4 bytes, under the 5-byte minimum (type + tag + crc).
|
||||
wire = bytes([0x05, 0xAA, 0xBB, 0xCC, 0xDD])
|
||||
with pytest.raises(FrameTooShortError):
|
||||
decode_frame(wire)
|
||||
|
||||
|
||||
# ── read_frame with a BytesIO-like reader ──────────────────────
|
||||
|
||||
|
||||
class _FakeReader:
|
||||
def __init__(self, data: bytes):
|
||||
self._data = bytearray(data)
|
||||
|
||||
def read(self, n: int = 1) -> bytes:
|
||||
if not self._data:
|
||||
return b""
|
||||
chunk = bytes(self._data[:n])
|
||||
del self._data[:n]
|
||||
return chunk
|
||||
|
||||
|
||||
def test_read_frame_yields_decoded_frame():
|
||||
wire = encode_frame(TYPE_PING, 0x0001, b"")
|
||||
r = _FakeReader(wire)
|
||||
frame = read_frame(r)
|
||||
assert frame == Frame(type_id=TYPE_PING, tag=0x0001, payload=b"")
|
||||
|
||||
|
||||
def test_read_frame_returns_none_on_timeout():
|
||||
r = _FakeReader(b"")
|
||||
assert read_frame(r) is None
|
||||
|
||||
|
||||
def test_read_frame_handles_back_to_back_frames():
|
||||
a = encode_frame(TYPE_PING, 1, b"")
|
||||
b = encode_frame(TYPE_PING, 2, b"")
|
||||
r = _FakeReader(a + b)
|
||||
assert read_frame(r).tag == 1
|
||||
assert read_frame(r).tag == 2
|
||||
|
||||
|
||||
# ── Oversized payloads rejected ────────────────────────────────
|
||||
|
||||
|
||||
def test_encode_rejects_oversized_payload():
|
||||
from donglora.frame import MAX_PAYLOAD_FIELD, FrameTooLargeError
|
||||
|
||||
with pytest.raises(FrameTooLargeError):
|
||||
encode_frame(0xC0, 0x0000, b"\x42" * (MAX_PAYLOAD_FIELD + 1))
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Per-modulation config encode/decode tests.
|
||||
|
||||
The LoRa test pins the Appendix C.2.3 canonical EU868 byte sequence.
|
||||
The others round-trip through encode → decode.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from donglora.modulation import (
|
||||
FlrcBitrate,
|
||||
FlrcBt,
|
||||
FlrcCodingRate,
|
||||
FlrcConfig,
|
||||
FlrcPreambleLen,
|
||||
FskConfig,
|
||||
LoRaBandwidth,
|
||||
LoRaCodingRate,
|
||||
LoRaConfig,
|
||||
LoRaHeaderMode,
|
||||
LrFhssBandwidth,
|
||||
LrFhssCodingRate,
|
||||
LrFhssConfig,
|
||||
LrFhssGrid,
|
||||
decode_modulation,
|
||||
encode_modulation,
|
||||
)
|
||||
|
||||
# ── LoRa — spec bytes ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_lora_default_matches_appendix_c23():
|
||||
cfg = LoRaConfig.default()
|
||||
expected = bytes(
|
||||
[
|
||||
0xA0,
|
||||
0x27,
|
||||
0xBE,
|
||||
0x33, # freq_hz = 868_100_000
|
||||
0x07, # sf
|
||||
0x07, # bw = Khz125
|
||||
0x00, # cr = 4/5
|
||||
0x08,
|
||||
0x00, # preamble_len = 8
|
||||
0x24,
|
||||
0x14, # sync_word = 0x1424
|
||||
0x0E, # tx_power_dbm = 14
|
||||
0x00, # header explicit
|
||||
0x01, # payload_crc on
|
||||
0x00, # iq normal
|
||||
],
|
||||
)
|
||||
assert cfg.encode() == expected
|
||||
|
||||
|
||||
def test_lora_roundtrip():
|
||||
cfg = LoRaConfig(
|
||||
freq_hz=915_000_000,
|
||||
sf=10,
|
||||
bw=LoRaBandwidth.KHZ_250,
|
||||
cr=LoRaCodingRate.CR_4_8,
|
||||
preamble_len=32,
|
||||
sync_word=0xABCD,
|
||||
tx_power_dbm=22,
|
||||
header_mode=LoRaHeaderMode.IMPLICIT,
|
||||
payload_crc=False,
|
||||
iq_invert=True,
|
||||
)
|
||||
assert LoRaConfig.decode(cfg.encode()) == cfg
|
||||
|
||||
|
||||
def test_lora_decode_rejects_wrong_length():
|
||||
with pytest.raises(ValueError):
|
||||
LoRaConfig.decode(b"\x00" * 14)
|
||||
|
||||
|
||||
def test_lora_decode_rejects_invalid_enum():
|
||||
buf = bytearray(LoRaConfig.default().encode())
|
||||
buf[5] = 99 # invalid bandwidth
|
||||
with pytest.raises(ValueError):
|
||||
LoRaConfig.decode(bytes(buf))
|
||||
|
||||
|
||||
# ── FSK ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fsk_roundtrip_with_sync_word():
|
||||
cfg = FskConfig(
|
||||
freq_hz=868_000_000,
|
||||
bitrate_bps=50_000,
|
||||
freq_dev_hz=25_000,
|
||||
rx_bw=0x1A,
|
||||
preamble_len=32,
|
||||
sync_word=b"\x12\x34\x56\x78",
|
||||
)
|
||||
assert FskConfig.decode(cfg.encode()) == cfg
|
||||
|
||||
|
||||
def test_fsk_rejects_oversized_sync_word():
|
||||
cfg = FskConfig(
|
||||
freq_hz=0,
|
||||
bitrate_bps=0,
|
||||
freq_dev_hz=0,
|
||||
rx_bw=0,
|
||||
preamble_len=0,
|
||||
sync_word=b"\x00" * 9,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
cfg.encode()
|
||||
|
||||
|
||||
# ── LR-FHSS ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_lr_fhss_roundtrip():
|
||||
cfg = LrFhssConfig(
|
||||
freq_hz=915_000_000,
|
||||
bw=LrFhssBandwidth.KHZ_137,
|
||||
cr=LrFhssCodingRate.CR_2_3,
|
||||
grid=LrFhssGrid.KHZ_25,
|
||||
hopping=True,
|
||||
tx_power_dbm=14,
|
||||
)
|
||||
assert LrFhssConfig.decode(cfg.encode()) == cfg
|
||||
|
||||
|
||||
def test_lr_fhss_rejects_nonzero_reserved():
|
||||
cfg = LrFhssConfig(
|
||||
freq_hz=0,
|
||||
bw=LrFhssBandwidth.KHZ_39,
|
||||
cr=LrFhssCodingRate.CR_1_3,
|
||||
grid=LrFhssGrid.KHZ_25,
|
||||
hopping=False,
|
||||
tx_power_dbm=0,
|
||||
)
|
||||
buf = bytearray(cfg.encode())
|
||||
buf[9] = 1
|
||||
with pytest.raises(ValueError):
|
||||
LrFhssConfig.decode(bytes(buf))
|
||||
|
||||
|
||||
# ── FLRC ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_flrc_roundtrip():
|
||||
cfg = FlrcConfig(
|
||||
freq_hz=2_400_000_000,
|
||||
bitrate=FlrcBitrate.KBPS_1300,
|
||||
cr=FlrcCodingRate.CR_3_4,
|
||||
bt=FlrcBt.BT_0_5,
|
||||
preamble_len=FlrcPreambleLen.BITS_24,
|
||||
sync_word=0x12345678,
|
||||
tx_power_dbm=10,
|
||||
)
|
||||
assert FlrcConfig.decode(cfg.encode()) == cfg
|
||||
|
||||
|
||||
# ── Sum type (modulation_id prefix) ───────────────────────────
|
||||
|
||||
|
||||
def test_encode_modulation_prepends_lora_id():
|
||||
cfg = LoRaConfig.default()
|
||||
wire = encode_modulation(cfg)
|
||||
assert wire[0] == 0x01
|
||||
assert wire[1:] == cfg.encode()
|
||||
|
||||
|
||||
def test_decode_modulation_rejects_unknown_id():
|
||||
with pytest.raises(ValueError):
|
||||
decode_modulation(bytes([0x99]) + b"\x00" * 15)
|
||||
|
||||
|
||||
def test_decode_modulation_dispatches_correctly():
|
||||
for cfg in (
|
||||
LoRaConfig.default(),
|
||||
FskConfig(
|
||||
freq_hz=868_000_000,
|
||||
bitrate_bps=9_600,
|
||||
freq_dev_hz=5_000,
|
||||
rx_bw=0,
|
||||
preamble_len=16,
|
||||
sync_word=b"\x12\x34",
|
||||
),
|
||||
LrFhssConfig(
|
||||
freq_hz=915_000_000,
|
||||
bw=LrFhssBandwidth.KHZ_137,
|
||||
cr=LrFhssCodingRate.CR_2_3,
|
||||
grid=LrFhssGrid.KHZ_25,
|
||||
hopping=True,
|
||||
tx_power_dbm=14,
|
||||
),
|
||||
FlrcConfig(
|
||||
freq_hz=2_400_000_000,
|
||||
bitrate=FlrcBitrate.KBPS_1300,
|
||||
cr=FlrcCodingRate.CR_3_4,
|
||||
bt=FlrcBt.BT_0_5,
|
||||
preamble_len=FlrcPreambleLen.BITS_24,
|
||||
sync_word=0x12345678,
|
||||
tx_power_dbm=10,
|
||||
),
|
||||
):
|
||||
wire = encode_modulation(cfg)
|
||||
assert decode_modulation(wire) == cfg
|
||||
@@ -1,254 +0,0 @@
|
||||
"""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
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Session-level tests: tag correlation, async events, auto-recovery.
|
||||
|
||||
Uses a `FakeTransport` — a thread-safe in-memory duplex pipe with a
|
||||
simple script of pre-canned firmware responses. Every test exercises
|
||||
the real frame codec; only the transport is faked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from donglora.commands import (
|
||||
TYPE_GET_INFO,
|
||||
TYPE_PING,
|
||||
TYPE_RX_START,
|
||||
TYPE_SET_CONFIG,
|
||||
TYPE_TX,
|
||||
)
|
||||
from donglora.errors import (
|
||||
BusyError,
|
||||
Cancelled,
|
||||
ChannelBusy,
|
||||
NotConfiguredError,
|
||||
)
|
||||
from donglora.events import (
|
||||
TYPE_ERR,
|
||||
TYPE_OK,
|
||||
TYPE_RX,
|
||||
TYPE_TX_DONE,
|
||||
Owner,
|
||||
RxEvent,
|
||||
RxOrigin,
|
||||
SetConfigResult,
|
||||
SetConfigResultCode,
|
||||
TxDone,
|
||||
TxResult,
|
||||
)
|
||||
from donglora.frame import decode_frame, encode_frame
|
||||
from donglora.info import Info, RadioChipId
|
||||
from donglora.modulation import LoRaConfig
|
||||
from donglora.session import Session
|
||||
|
||||
# ── A tiny in-memory transport that simulates a device ─────────
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
"""Thread-safe in-memory duplex pipe with a scripted device."""
|
||||
|
||||
def __init__(self, *, script: Callable[[int, int, bytes], list[bytes]] | None = None):
|
||||
self._read_buf = bytearray()
|
||||
self._read_cond = threading.Condition()
|
||||
self._write_buf = bytearray()
|
||||
self._write_cond = threading.Condition()
|
||||
self._closed = False
|
||||
self._script = script or _default_script
|
||||
self._writer_thread = threading.Thread(
|
||||
target=self._writer_loop,
|
||||
name="fake-transport-writer",
|
||||
daemon=True,
|
||||
)
|
||||
self._writer_thread.start()
|
||||
self.log: list[bytes] = []
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
with self._write_cond:
|
||||
self._write_buf.extend(data)
|
||||
self._write_cond.notify_all()
|
||||
return len(data)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
def read(self, n: int = 1) -> bytes:
|
||||
with self._read_cond:
|
||||
while not self._read_buf and not self._closed:
|
||||
self._read_cond.wait(timeout=0.1)
|
||||
if self._closed:
|
||||
return b""
|
||||
chunk = bytes(self._read_buf[:n])
|
||||
del self._read_buf[:n]
|
||||
return chunk
|
||||
|
||||
def close(self) -> None:
|
||||
with self._read_cond:
|
||||
self._closed = True
|
||||
self._read_cond.notify_all()
|
||||
with self._write_cond:
|
||||
self._write_cond.notify_all()
|
||||
|
||||
def _writer_loop(self) -> None:
|
||||
"""Read framed commands off _write_buf, feed to the script,
|
||||
push responses onto _read_buf.
|
||||
"""
|
||||
while not self._closed:
|
||||
with self._write_cond:
|
||||
while not self._write_buf and not self._closed:
|
||||
self._write_cond.wait(timeout=0.1)
|
||||
if self._closed:
|
||||
return
|
||||
# Collect complete frames (ending in 0x00) from write_buf.
|
||||
frames_raw: list[bytes] = []
|
||||
while True:
|
||||
try:
|
||||
idx = self._write_buf.index(0)
|
||||
except ValueError:
|
||||
break
|
||||
frames_raw.append(bytes(self._write_buf[:idx]))
|
||||
del self._write_buf[: idx + 1]
|
||||
for raw in frames_raw:
|
||||
try:
|
||||
frame = decode_frame(raw)
|
||||
except Exception:
|
||||
continue
|
||||
self.log.append(raw)
|
||||
responses = self._script(frame.type_id, frame.tag, frame.payload)
|
||||
if responses:
|
||||
with self._read_cond:
|
||||
for r in responses:
|
||||
self._read_buf.extend(r)
|
||||
self._read_cond.notify_all()
|
||||
|
||||
# Kick an async event (e.g. RX) into the read stream.
|
||||
def push_async(self, type_id: int, tag: int, payload: bytes) -> None:
|
||||
wire = encode_frame(type_id, tag, payload)
|
||||
with self._read_cond:
|
||||
self._read_buf.extend(wire)
|
||||
self._read_cond.notify_all()
|
||||
|
||||
|
||||
def _default_script(type_id: int, tag: int, payload: bytes) -> list[bytes]: # noqa: ARG001
|
||||
"""Reasonable default: ACK every command with a shaped OK."""
|
||||
if type_id == TYPE_PING:
|
||||
return [encode_frame(TYPE_OK, tag, b"")]
|
||||
if type_id == TYPE_GET_INFO:
|
||||
info = _make_info()
|
||||
return [encode_frame(TYPE_OK, tag, info.encode())]
|
||||
if type_id == TYPE_SET_CONFIG:
|
||||
result = SetConfigResult(
|
||||
result=SetConfigResultCode.APPLIED,
|
||||
owner=Owner.MINE,
|
||||
current=LoRaConfig.default(),
|
||||
)
|
||||
return [encode_frame(TYPE_OK, tag, result.encode())]
|
||||
if type_id == TYPE_RX_START:
|
||||
return [encode_frame(TYPE_OK, tag, b"")]
|
||||
if type_id == TYPE_TX:
|
||||
td = TxDone(result=TxResult.TRANSMITTED, airtime_us=30_976)
|
||||
return [
|
||||
encode_frame(TYPE_OK, tag, b""),
|
||||
encode_frame(TYPE_TX_DONE, tag, td.encode()),
|
||||
]
|
||||
return [encode_frame(TYPE_OK, tag, b"")]
|
||||
|
||||
|
||||
def _make_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=1 | (1 << 16), # LoRa + CAD_BEFORE_TX
|
||||
supported_sf_bitmap=0x1FE0,
|
||||
supported_bw_bitmap=0x03FF,
|
||||
max_payload_bytes=255,
|
||||
rx_queue_capacity=32,
|
||||
tx_queue_capacity=1,
|
||||
freq_min_hz=150_000_000,
|
||||
freq_max_hz=960_000_000,
|
||||
tx_power_min_dbm=-9,
|
||||
tx_power_max_dbm=22,
|
||||
mcu_uid=b"\xde\xad\xbe\xef\x01\x23",
|
||||
radio_uid=b"",
|
||||
)
|
||||
|
||||
|
||||
# ── Session tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport():
|
||||
t = FakeTransport()
|
||||
yield t
|
||||
t.close()
|
||||
|
||||
|
||||
def test_session_ping(transport):
|
||||
s = Session(transport)
|
||||
try:
|
||||
s.ping(timeout=2.0)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_get_info_returns_info_struct(transport):
|
||||
s = Session(transport)
|
||||
try:
|
||||
info = s.get_info(timeout=2.0)
|
||||
assert isinstance(info, Info)
|
||||
assert info.proto_major == 1
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_set_config_returns_set_config_result(transport):
|
||||
s = Session(transport)
|
||||
try:
|
||||
result = s.set_config(LoRaConfig.default())
|
||||
assert isinstance(result, SetConfigResult)
|
||||
assert result.result == SetConfigResultCode.APPLIED
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_transmit_returns_tx_done(transport):
|
||||
s = Session(transport)
|
||||
try:
|
||||
td = s.transmit(b"Hello")
|
||||
assert td.result == TxResult.TRANSMITTED
|
||||
assert td.airtime_us == 30_976
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_transmit_channel_busy(transport):
|
||||
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
|
||||
if type_id == TYPE_TX:
|
||||
td = TxDone(result=TxResult.CHANNEL_BUSY, airtime_us=0)
|
||||
return [
|
||||
encode_frame(TYPE_OK, tag, b""),
|
||||
encode_frame(TYPE_TX_DONE, tag, td.encode()),
|
||||
]
|
||||
return _default_script(type_id, tag, payload)
|
||||
|
||||
transport._script = script
|
||||
s = Session(transport)
|
||||
try:
|
||||
with pytest.raises(ChannelBusy):
|
||||
s.transmit(b"x")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_transmit_cancelled(transport):
|
||||
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
|
||||
if type_id == TYPE_TX:
|
||||
td = TxDone(result=TxResult.CANCELLED, airtime_us=0)
|
||||
return [
|
||||
encode_frame(TYPE_OK, tag, b""),
|
||||
encode_frame(TYPE_TX_DONE, tag, td.encode()),
|
||||
]
|
||||
return _default_script(type_id, tag, payload)
|
||||
|
||||
transport._script = script
|
||||
s = Session(transport)
|
||||
try:
|
||||
with pytest.raises(Cancelled):
|
||||
s.transmit(b"x")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_err_not_configured_raises(transport):
|
||||
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
|
||||
if type_id == TYPE_TX:
|
||||
return [encode_frame(TYPE_ERR, tag, b"\x03\x00")]
|
||||
return _default_script(type_id, tag, payload)
|
||||
|
||||
transport._script = script
|
||||
s = Session(transport)
|
||||
try:
|
||||
with pytest.raises(NotConfiguredError):
|
||||
s.transmit(b"x")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_err_ebusy_raises(transport):
|
||||
def script(type_id: int, tag: int, payload: bytes) -> list[bytes]:
|
||||
if type_id == TYPE_TX:
|
||||
return [encode_frame(TYPE_ERR, tag, b"\x06\x00")]
|
||||
return _default_script(type_id, tag, payload)
|
||||
|
||||
transport._script = script
|
||||
s = Session(transport)
|
||||
try:
|
||||
with pytest.raises(BusyError):
|
||||
s.transmit(b"x")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_async_rx_event_arrives_on_queue(transport):
|
||||
s = Session(transport)
|
||||
try:
|
||||
rx = RxEvent(
|
||||
rssi_dbm=-73.5,
|
||||
snr_db=9.5,
|
||||
freq_err_hz=0,
|
||||
timestamp_us=100,
|
||||
crc_valid=True,
|
||||
packets_dropped=0,
|
||||
origin=RxOrigin.OTA,
|
||||
data=b"\x01\x02\x03",
|
||||
)
|
||||
transport.push_async(TYPE_RX, 0, rx.encode())
|
||||
delivered = s.next_rx(timeout=2.0)
|
||||
assert delivered is not None
|
||||
assert delivered.data == b"\x01\x02\x03"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_session_tag_correlation_across_concurrent_sends(transport):
|
||||
# Two threads each send a PING. Both must resolve against their own tag.
|
||||
s = Session(transport)
|
||||
try:
|
||||
errors: list[Exception] = []
|
||||
|
||||
def do_ping() -> None:
|
||||
try:
|
||||
s.ping(timeout=2.0)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=do_ping) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=3.0)
|
||||
assert errors == []
|
||||
finally:
|
||||
s.close()
|
||||
Reference in new issue
Block a user