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,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()
|
||||
Reference in new issue
Block a user