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.
This commit is contained in:
12 files changed
+1693
-694
No files matched your search
+177
-141
@@ -1,158 +1,194 @@
|
||||
"""Tests for mux interception logic — ported from mux-rs/src/intercept.rs tests."""
|
||||
"""Unit tests for :mod:`donglora_mux.intercept`.
|
||||
|
||||
Ports the ``intercept`` test cases from ``mux-rs/src/intercept.rs``,
|
||||
adapted for Python / asyncio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from donglora.protocol import RADIO_CONFIG_SIZE
|
||||
from donglora_mux import (
|
||||
CMD_TAG_SET_CONFIG,
|
||||
CMD_TAG_START_RX,
|
||||
CMD_TAG_STOP_RX,
|
||||
ERROR_INVALID_CONFIG,
|
||||
TAG_ERROR,
|
||||
TAG_OK,
|
||||
Client,
|
||||
MuxDaemon,
|
||||
from donglora.commands import (
|
||||
TYPE_GET_INFO,
|
||||
TYPE_PING,
|
||||
TYPE_RX_START,
|
||||
TYPE_RX_STOP,
|
||||
TYPE_SET_CONFIG,
|
||||
encode_set_config_payload,
|
||||
)
|
||||
from donglora.events import TYPE_OK, Owner, SetConfigResult, SetConfigResultCode
|
||||
from donglora.modulation import LoRaBandwidth, LoRaCodingRate, LoRaConfig
|
||||
from donglora_mux.intercept import DecisionKind, MuxState, decide
|
||||
from donglora_mux.session import ClientSession
|
||||
|
||||
|
||||
def _make_daemon() -> MuxDaemon:
|
||||
"""Create a MuxDaemon with no real serial port (for interception tests only)."""
|
||||
daemon = MuxDaemon.__new__(MuxDaemon)
|
||||
daemon.serial_port = "/dev/null"
|
||||
daemon.socket_path = "/tmp/test-mux.sock"
|
||||
daemon.tcp_addr = None
|
||||
daemon.ser = None
|
||||
daemon.clients = {}
|
||||
daemon.cmd_queue = asyncio.Queue()
|
||||
daemon.pending_response = None
|
||||
daemon.locked_config = None
|
||||
daemon._shutdown = asyncio.Event()
|
||||
return daemon
|
||||
def _lora(freq_hz: int = 910_525_000) -> LoRaConfig:
|
||||
return LoRaConfig(
|
||||
freq_hz=freq_hz,
|
||||
sf=7,
|
||||
bw=LoRaBandwidth.KHZ_62_5,
|
||||
cr=LoRaCodingRate.CR_4_5,
|
||||
preamble_len=16,
|
||||
sync_word=0x1424,
|
||||
tx_power_dbm=20,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(daemon: MuxDaemon) -> Client:
|
||||
"""Add a mock client to the daemon and return it."""
|
||||
reader = asyncio.StreamReader()
|
||||
# We can't easily create a StreamWriter without a transport, so we'll
|
||||
# test the interception logic directly via _maybe_intercept
|
||||
client = Client.__new__(Client)
|
||||
client.id = Client._next_id
|
||||
Client._next_id += 1
|
||||
client.reader = reader
|
||||
client.writer = None # type: ignore[assignment]
|
||||
client.rx_interested = False
|
||||
client.send_queue = asyncio.Queue(maxsize=256)
|
||||
client._sender_task = None
|
||||
daemon.clients[client.id] = client
|
||||
return client
|
||||
def _make_sessions(n: int) -> dict[int, ClientSession]:
|
||||
return {s.id: s for s in (ClientSession() for _ in range(n))}
|
||||
|
||||
|
||||
class TestSetConfigIntercept:
|
||||
def test_single_client_forwards(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
cmd = bytes([CMD_TAG_SET_CONFIG]) + bytes(RADIO_CONFIG_SIZE)
|
||||
result = asyncio.get_event_loop().run_until_complete(daemon._maybe_intercept(c1.id, cmd))
|
||||
assert result is None # should forward to dongle
|
||||
|
||||
def test_first_with_multiple_forwards(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
_make_client(daemon)
|
||||
c2 = _make_client(daemon)
|
||||
cmd = bytes([CMD_TAG_SET_CONFIG]) + bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||
result = asyncio.get_event_loop().run_until_complete(daemon._maybe_intercept(c2.id, cmd))
|
||||
assert result is None # no locked config yet, forward
|
||||
|
||||
def test_matching_returns_ok(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
_make_client(daemon)
|
||||
c2 = _make_client(daemon)
|
||||
config_bytes = bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||
daemon.locked_config = config_bytes
|
||||
cmd = bytes([CMD_TAG_SET_CONFIG]) + config_bytes
|
||||
result = asyncio.get_event_loop().run_until_complete(daemon._maybe_intercept(c2.id, cmd))
|
||||
assert result == bytes([TAG_OK])
|
||||
|
||||
def test_conflicting_returns_error(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
_make_client(daemon)
|
||||
c2 = _make_client(daemon)
|
||||
daemon.locked_config = bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||
cmd = bytes([CMD_TAG_SET_CONFIG]) + bytes([99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
|
||||
result = asyncio.get_event_loop().run_until_complete(daemon._maybe_intercept(c2.id, cmd))
|
||||
assert result == bytes([TAG_ERROR, ERROR_INVALID_CONFIG])
|
||||
def _first_id(sessions: dict[int, ClientSession]) -> int:
|
||||
return min(sessions.keys())
|
||||
|
||||
|
||||
class TestStartRxIntercept:
|
||||
def test_first_client_forwards(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([CMD_TAG_START_RX]))
|
||||
)
|
||||
assert result is None # first interested client, forward
|
||||
|
||||
def test_already_interested_returns_ok(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
c1.rx_interested = True
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([CMD_TAG_START_RX]))
|
||||
)
|
||||
assert result == bytes([TAG_OK])
|
||||
|
||||
def test_others_interested_marks_and_returns_ok(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
c2 = _make_client(daemon)
|
||||
c1.rx_interested = True
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c2.id, bytes([CMD_TAG_START_RX]))
|
||||
)
|
||||
assert result == bytes([TAG_OK])
|
||||
assert c2.rx_interested is True
|
||||
def _second_id(sessions: dict[int, ClientSession]) -> int:
|
||||
ids = sorted(sessions.keys())
|
||||
return ids[1]
|
||||
|
||||
|
||||
class TestStopRxIntercept:
|
||||
def test_not_interested_returns_ok(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([CMD_TAG_STOP_RX]))
|
||||
)
|
||||
assert result == bytes([TAG_OK])
|
||||
|
||||
def test_others_remain_returns_ok(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
c2 = _make_client(daemon)
|
||||
c1.rx_interested = True
|
||||
c2.rx_interested = True
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([CMD_TAG_STOP_RX]))
|
||||
)
|
||||
assert result == bytes([TAG_OK])
|
||||
assert c1.rx_interested is False
|
||||
|
||||
def test_last_interested_forwards(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
c1.rx_interested = True
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([CMD_TAG_STOP_RX]))
|
||||
)
|
||||
assert result is None # last client, forward to dongle
|
||||
assert c1.rx_interested is False
|
||||
# ── SET_CONFIG ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOtherCommands:
|
||||
def test_not_intercepted(self) -> None:
|
||||
daemon = _make_daemon()
|
||||
c1 = _make_client(daemon)
|
||||
for tag in [0, 1, 5, 6, 7, 8]:
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
daemon._maybe_intercept(c1.id, bytes([tag]))
|
||||
)
|
||||
assert result is None
|
||||
def test_set_config_single_client_forwards():
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
payload = encode_set_config_payload(_lora())
|
||||
d = decide(TYPE_SET_CONFIG, payload, cid, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
|
||||
|
||||
def test_set_config_first_multi_client_forwards():
|
||||
sessions = _make_sessions(2)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
payload = encode_set_config_payload(_lora())
|
||||
d = decide(TYPE_SET_CONFIG, payload, cid, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
|
||||
|
||||
def test_set_config_matching_synthesizes_already_matched():
|
||||
sessions = _make_sessions(2)
|
||||
id_a = _first_id(sessions)
|
||||
id_b = _second_id(sessions)
|
||||
state = MuxState()
|
||||
state.locked = (id_a, _lora())
|
||||
payload = encode_set_config_payload(_lora())
|
||||
d = decide(TYPE_SET_CONFIG, payload, id_b, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
assert d.type_id == TYPE_OK
|
||||
result = SetConfigResult.decode(d.payload)
|
||||
assert result.result == SetConfigResultCode.ALREADY_MATCHED
|
||||
assert result.owner == Owner.OTHER
|
||||
|
||||
|
||||
def test_set_config_conflict_synthesizes_locked_mismatch_with_current_echoed():
|
||||
sessions = _make_sessions(2)
|
||||
id_a = _first_id(sessions)
|
||||
id_b = _second_id(sessions)
|
||||
state = MuxState()
|
||||
# A's lock is 910.525 MHz; B requests 915 MHz.
|
||||
state.locked = (id_a, _lora(910_525_000))
|
||||
payload = encode_set_config_payload(_lora(915_000_000))
|
||||
d = decide(TYPE_SET_CONFIG, payload, id_b, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
result = SetConfigResult.decode(d.payload)
|
||||
assert result.result == SetConfigResultCode.LOCKED_MISMATCH
|
||||
assert result.owner == Owner.OTHER
|
||||
# The echoed `current` must reflect A's locked modulation, not B's request.
|
||||
assert isinstance(result.current, LoRaConfig)
|
||||
assert result.current.freq_hz == 910_525_000
|
||||
|
||||
|
||||
def test_set_config_owner_reconfig_forwards():
|
||||
sessions = _make_sessions(2)
|
||||
id_a = _first_id(sessions)
|
||||
state = MuxState()
|
||||
state.locked = (id_a, _lora(910_525_000))
|
||||
# A re-tunes — should forward, daemon updates lock on APPLIED.
|
||||
payload = encode_set_config_payload(_lora(915_000_000))
|
||||
d = decide(TYPE_SET_CONFIG, payload, id_a, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
|
||||
|
||||
# ── RX_START ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rx_start_first_forwards():
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
d = decide(TYPE_RX_START, b"", cid, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
|
||||
|
||||
def test_rx_start_when_others_listening_marks_and_synthesizes_ok():
|
||||
sessions = _make_sessions(2)
|
||||
state = MuxState()
|
||||
id_a = _first_id(sessions)
|
||||
id_b = _second_id(sessions)
|
||||
sessions[id_a].rx_interested = True
|
||||
d = decide(TYPE_RX_START, b"", id_b, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
assert d.type_id == TYPE_OK
|
||||
assert d.payload == b""
|
||||
assert sessions[id_b].rx_interested is True
|
||||
|
||||
|
||||
def test_rx_start_already_interested_synthesizes_ok():
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
sessions[cid].rx_interested = True
|
||||
d = decide(TYPE_RX_START, b"", cid, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
assert d.type_id == TYPE_OK
|
||||
|
||||
|
||||
# ── RX_STOP ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rx_stop_not_interested_synthesizes_ok():
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
d = decide(TYPE_RX_STOP, b"", cid, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
assert d.type_id == TYPE_OK
|
||||
|
||||
|
||||
def test_rx_stop_with_others_listening_clears_caller_and_synthesizes_ok():
|
||||
sessions = _make_sessions(2)
|
||||
state = MuxState()
|
||||
id_a = _first_id(sessions)
|
||||
id_b = _second_id(sessions)
|
||||
sessions[id_a].rx_interested = True
|
||||
sessions[id_b].rx_interested = True
|
||||
d = decide(TYPE_RX_STOP, b"", id_a, state, sessions)
|
||||
assert d.kind == DecisionKind.SYNTHESIZE
|
||||
assert d.type_id == TYPE_OK
|
||||
assert sessions[id_a].rx_interested is False
|
||||
assert sessions[id_b].rx_interested is True
|
||||
|
||||
|
||||
def test_rx_stop_last_interested_forwards_and_clears():
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
sessions[cid].rx_interested = True
|
||||
d = decide(TYPE_RX_STOP, b"", cid, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
assert sessions[cid].rx_interested is False
|
||||
|
||||
|
||||
# ── Other commands ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("type_id", [TYPE_PING, TYPE_GET_INFO])
|
||||
def test_other_commands_forward(type_id):
|
||||
sessions = _make_sessions(1)
|
||||
state = MuxState()
|
||||
cid = _first_id(sessions)
|
||||
d = decide(type_id, b"", cid, state, sessions)
|
||||
assert d.kind == DecisionKind.FORWARD
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for :mod:`donglora_mux.tagmap`.
|
||||
|
||||
Mirrors the ``TagMapper`` test cases in ``mux-rs/src/daemon.rs``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from donglora.commands import TYPE_PING, TYPE_TX
|
||||
from donglora_mux.tagmap import TagMapper
|
||||
|
||||
|
||||
def test_alloc_skips_zero():
|
||||
m = TagMapper()
|
||||
# Allocating many tags in a row, each must be non-zero. Even after
|
||||
# many alloc/take cycles, we should never hand out 0 (reserved for
|
||||
# async events).
|
||||
for _ in range(1024):
|
||||
tag = m.alloc(1, 100, TYPE_PING)
|
||||
assert tag != 0
|
||||
m.take(tag)
|
||||
|
||||
|
||||
def test_take_returns_mapping_and_removes():
|
||||
m = TagMapper()
|
||||
tag = m.alloc(42, 7, TYPE_TX)
|
||||
p = m.take(tag)
|
||||
assert p is not None
|
||||
assert p.client_id == 42
|
||||
assert p.upstream_tag == 7
|
||||
assert p.cmd_type == TYPE_TX
|
||||
assert len(m) == 0
|
||||
|
||||
|
||||
def test_peek_keeps_mapping():
|
||||
m = TagMapper()
|
||||
tag = m.alloc(42, 7, TYPE_TX)
|
||||
p = m.peek(tag)
|
||||
assert p is not None
|
||||
assert p.client_id == 42
|
||||
assert len(m) == 1
|
||||
|
||||
|
||||
def test_drop_client_removes_only_that_clients_tags():
|
||||
m = TagMapper()
|
||||
t_a1 = m.alloc(1, 10, TYPE_PING)
|
||||
t_a2 = m.alloc(1, 11, TYPE_PING)
|
||||
t_b = m.alloc(2, 12, TYPE_PING)
|
||||
m.drop_client(1)
|
||||
assert m.take(t_a1) is None
|
||||
assert m.take(t_a2) is None
|
||||
assert m.take(t_b) is not None
|
||||
|
||||
|
||||
def test_concurrent_alloc_yields_unique_tags():
|
||||
m = TagMapper()
|
||||
tags = {m.alloc(1, i, TYPE_PING) for i in range(100)}
|
||||
assert len(tags) == 100
|
||||
assert 0 not in tags
|
||||
|
||||
|
||||
def test_allocations_recover_after_take():
|
||||
# After a take(), the freed tag can be re-used (post-wrap scenario).
|
||||
m = TagMapper()
|
||||
first = m.alloc(1, 0, TYPE_PING)
|
||||
m.take(first)
|
||||
# Keep filling until we wrap and get `first` back. Don't assert
|
||||
# eagerly because the allocator is monotonic; just prove it
|
||||
# terminates within the u16 space.
|
||||
seen = {first}
|
||||
while True:
|
||||
t = m.alloc(1, 0, TYPE_PING)
|
||||
if t in seen:
|
||||
break
|
||||
seen.add(t)
|
||||
m.take(t)
|
||||
if len(seen) > 0x10000: # pragma: no cover — safety
|
||||
raise AssertionError("allocator failed to recycle slots")
|
||||
Reference in new issue
Block a user