Files
mux-py/tests/test_multiclient.py
T
swaits b15b661c2d feat!: mux-py on DongLoRa Protocol v2 with tag-aware dispatch
The 1.0 Python mux, mirroring the Rust donglora-mux 1.0 one-for-one and implementing the full §13 multi-client semantics on the DongLoRa Protocol v2 wire format.

* Structural rewrite. donglora_mux/{daemon,intercept,session,tagmap}.py replace the 601-line __init__.py monolith.

* Tag-aware dispatch. TagMapper with skip-zero allocation and per-client bulk drop on disconnect. Multiple clients can have commands in flight concurrently; the single-oneshot response slot is gone.

* Full §13.2 multi-client SET_CONFIG semantics. Tracks (client_id, modulation) lock owner and synthesizes OK(APPLIED/MINE), OK(ALREADY_MATCHED/OTHER), or OK(LOCKED_MISMATCH/OTHER).

* TX loopback fan-out (§13.4). On TX_DONE(TRANSMITTED) the mux synthesizes an RX frame with origin = LOCAL_LOOPBACK for every connected client except the transmitter.

* Async ERR(EFRAME) fan-out: tag-0 framing errors are broadcast to all clients.

* Per-client TX cache on ClientSession so the loopback fan-out does not need the sender to re-buffer.

* Socket and serial fcntl.flock to prevent multiple mux instances from fighting over the same dongle or socket path.

* 500 ms keepalive matching PROTOCOL.md §3.4 (2x margin on the 1 s inactivity timer), replacing the 5 s cadence from 0.x.

* Clean SIGINT / SIGTERM shutdown: per-client handler tasks are tracked and cancelled, so blocked reader.read() calls no longer pin the event loop open.
2026-04-22 09:55:28 -06:00

215 lines
7.1 KiB
Python

"""Integration tests for :class:`donglora_mux.daemon.MuxDaemon`.
Drives the daemon's dispatch logic directly without spinning up a real
serial port — we push synthetic dongle frames through
``_dispatch_device_frame`` and inspect what ends up in each client
session's queue.
"""
from __future__ import annotations
import asyncio
import pytest
from donglora.commands import (
TYPE_PING,
TYPE_SET_CONFIG,
TYPE_TX,
encode_set_config_payload,
encode_tx_payload,
)
from donglora.events import (
TYPE_OK,
TYPE_RX,
TYPE_TX_DONE,
Owner,
RxEvent,
RxOrigin,
SetConfigResult,
SetConfigResultCode,
TxDone,
TxResult,
)
from donglora.frame import Frame, decode_frame, encode_frame
from donglora.modulation import LoRaBandwidth, LoRaCodingRate, LoRaConfig
from donglora_mux.daemon import MuxDaemon
from donglora_mux.session import ClientSession
from donglora_mux.tagmap import TagMapper
def _lora(freq: int = 910_525_000) -> LoRaConfig:
return LoRaConfig(
freq_hz=freq,
sf=7,
bw=LoRaBandwidth.KHZ_62_5,
cr=LoRaCodingRate.CR_4_5,
preamble_len=16,
sync_word=0x1424,
tx_power_dbm=20,
)
def _make_daemon() -> MuxDaemon:
# Construct without touching filesystem / serial — we're exercising
# the dispatch surface directly.
return MuxDaemon("/dev/null", "/tmp/test-mux.sock", tcp_addr=None)
def _drain_queue(session: ClientSession) -> list[Frame]:
"""Non-blocking: drain whatever is currently in the session's queue."""
out: list[Frame] = []
while True:
try:
raw = session._queue.get_nowait() # type: ignore[attr-defined]
except asyncio.QueueEmpty:
return out
# Strip trailing 0x00 sentinel before decode_frame.
assert raw.endswith(b"\x00")
out.append(decode_frame(raw[:-1]))
@pytest.fixture
def daemon():
d = _make_daemon()
# Two sessions, manually added.
a = ClientSession()
b = ClientSession()
d.sessions[a.id] = a
d.sessions[b.id] = b
yield d, a, b
async def test_dispatch_routes_tagged_response_to_owning_client(daemon):
d, a, b = daemon
# A issued a PING with upstream tag 0x0042.
device_tag = d.tagmap.alloc(a.id, 0x0042, TYPE_PING)
# Dongle replies with OK on the device_tag.
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=device_tag, payload=b""))
a_frames = _drain_queue(a)
b_frames = _drain_queue(b)
assert len(a_frames) == 1
assert a_frames[0].type_id == TYPE_OK
assert a_frames[0].tag == 0x0042
assert b_frames == [] # Only A gets the routed response.
async def test_dispatch_fans_out_async_rx_to_all_clients(daemon):
d, a, b = daemon
rx = RxEvent(
rssi_dbm=-80.0,
snr_db=7.5,
freq_err_hz=0,
timestamp_us=1_000_000,
crc_valid=True,
packets_dropped=0,
origin=RxOrigin.OTA,
data=b"hello",
)
await d._dispatch_device_frame(Frame(type_id=TYPE_RX, tag=0, payload=rx.encode()))
a_frames = _drain_queue(a)
b_frames = _drain_queue(b)
assert len(a_frames) == 1
assert len(b_frames) == 1
assert a_frames[0].type_id == TYPE_RX
assert a_frames[0].tag == 0
async def test_set_config_applied_installs_lock(daemon):
d, a, _b = daemon
cfg = _lora()
device_tag = d.tagmap.alloc(a.id, 0x0099, TYPE_SET_CONFIG)
result = SetConfigResult(result=SetConfigResultCode.APPLIED, owner=Owner.MINE, current=cfg)
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=device_tag, payload=result.encode()))
# Lock should now be installed with A as owner and the applied cfg.
assert d.state.locked is not None
owner_id, locked_cfg = d.state.locked
assert owner_id == a.id
assert locked_cfg.freq_hz == cfg.freq_hz
async def test_tx_done_transmitted_loopbacks_to_peers(daemon):
d, a, b = daemon
# A sent a TX of "hi" with upstream tag 0x00AA.
payload = encode_tx_payload(b"hi")
# Stash like the writer would.
a.tx_cache[0x00AA] = payload[1:] # strip flags byte
device_tag = d.tagmap.alloc(a.id, 0x00AA, TYPE_TX)
# Absorb the intermediate OK (mapping kept, tx_cache still populated).
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=device_tag, payload=b""))
# Terminal TX_DONE(TRANSMITTED).
td = TxDone(result=TxResult.TRANSMITTED, airtime_us=5_000)
await d._dispatch_device_frame(Frame(type_id=TYPE_TX_DONE, tag=device_tag, payload=td.encode()))
a_frames = _drain_queue(a)
b_frames = _drain_queue(b)
# A got OK (intermediate) + TX_DONE.
assert [f.type_id for f in a_frames] == [TYPE_OK, TYPE_TX_DONE]
assert all(f.tag == 0x00AA for f in a_frames)
# B got a loopback RX (tag=0, origin=LOCAL_LOOPBACK, data="hi").
assert len(b_frames) == 1
assert b_frames[0].type_id == TYPE_RX
assert b_frames[0].tag == 0
decoded = RxEvent # noqa: F841 — just a hint for readers
# Inline decode (RxEvent.decode if available, else pull fields from the wire payload).
buf = b_frames[0].payload
assert buf[19] == int(RxOrigin.LOCAL_LOOPBACK)
assert buf[20:] == b"hi"
async def test_disconnect_releases_lock(daemon):
d, a, _b = daemon
d.state.locked = (a.id, _lora())
# Simulate the post-writer-cancel cleanup step in _remove_client.
await d._remove_client(a)
assert d.state.locked is None
async def test_concurrent_tagged_commands_route_independently(daemon):
"""Two concurrent PINGs from different clients must correlate correctly."""
d, a, b = daemon
tag_a = d.tagmap.alloc(a.id, 0x0001, TYPE_PING)
tag_b = d.tagmap.alloc(b.id, 0x0001, TYPE_PING)
# Dongle replies for B first, then A.
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=tag_b, payload=b""))
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=tag_a, payload=b""))
a_frames = _drain_queue(a)
b_frames = _drain_queue(b)
assert len(a_frames) == 1
assert len(b_frames) == 1
# Each sees their own upstream tag, not the mux's internal device tag.
assert a_frames[0].tag == 0x0001
assert b_frames[0].tag == 0x0001
async def test_unsolicited_tagged_frame_is_dropped(daemon):
"""A frame with a tag that isn't in the pending map should be ignored,
not routed to a random client or crash the dispatcher."""
d, a, b = daemon
# Tag 9999 was never allocated.
await d._dispatch_device_frame(Frame(type_id=TYPE_OK, tag=9999, payload=b""))
assert _drain_queue(a) == []
assert _drain_queue(b) == []
def test_tag_mapper_isolation(daemon):
"""Regression check: tagmap should start empty per daemon instance."""
d, _a, _b = daemon
assert isinstance(d.tagmap, TagMapper)
assert len(d.tagmap) == 0
def test_encode_decode_roundtrip_sanity():
"""Sanity check: encode_frame + strip sentinel + decode_frame are inverses
for the shapes we push through the mux."""
payload = encode_set_config_payload(_lora())
wire = encode_frame(TYPE_SET_CONFIG, 42, payload)
assert wire.endswith(b"\x00")
frame = decode_frame(wire[:-1])
assert frame.type_id == TYPE_SET_CONFIG
assert frame.tag == 42
assert frame.payload == payload