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:
swaits committed 2026-04-22 09:55:28 -06:00
1 parent e64ce852ba
commit b15b661c2d
12 files changed
+1693 -694

No files matched your search

+56 -9
View File
@@ -1,19 +1,66 @@
# Changelog
## Unreleased
## 1.0.0 — 2026-04-21
### Breaking
- **Upgraded to the DongLoRa Protocol v2 wire format.** The 0.x line spoke the legacy
v0.1 protocol with hardcoded tag constants (`TAG_OK=4`, `TAG_ERROR=5`,
`CMD_TAG_SET_CONFIG=2`, …) that don't exist in v1.0. This release is
wire-incompatible with 0.x clients and firmware.
- **Pins `donglora>=1.0.0`** and uses its `donglora.frame` /
`donglora.events` / `donglora.commands` modules as the wire-level
backbone. No more in-package reimplementation of the wire protocol.
- **Structural rewrite** mirroring the Rust sibling
[`donglora-mux`](https://crates.io/crates/donglora-mux) one-to-one:
`donglora_mux/{daemon,intercept,session,tagmap}.py` replace the single
601-line `__init__.py`.
### Added
- Keepalive ping: detects unresponsive dongles (e.g. UART boards behind a
CP2102 bridge where the serial port stays open across ESP32 resets). Pings
every 5 seconds of idle, declares dongle lost after 2-second timeout.
- Radio state restoration after reconnect: saved SetConfig and StartRx are
re-sent to the dongle so clients don't need to know a reset happened.
- **`TagMapper`** — per-command device-tag allocator. Enables concurrent
out-of-order command/response correlation for multi-client setups
(the v0.1-era single-oneshot-response slot is gone).
- **Full `§13.2` multi-client `SET_CONFIG` semantics** — tracks the
`(client_id, modulation)` lock owner. Cross-client calls synthesize:
- `OK(APPLIED / MINE, params)` when the caller owns the lock
(forwarded to dongle).
- `OK(ALREADY_MATCHED / OTHER, current)` when another client holds
the lock and the requested params byte-match.
- `OK(LOCKED_MISMATCH / OTHER, current)` when they differ; `current`
echoes the lock owner's actual modulation.
- Lock auto-releases when the owning client disconnects.
- **TX loopback fan-out (`§13.4`)** — on `TX_DONE(TRANSMITTED)` the mux
synthesizes an `RX` frame with `origin = LOCAL_LOOPBACK` and sends it
to every connected client except the transmitter.
- **Async `ERR(EFRAME)` fan-out** — if the dongle emits async framing
errors (tag 0), they're broadcast to all clients rather than swallowed.
- **Per-client TX cache** on `ClientSession` so the loopback fan-out
doesn't need the sender to re-buffer the payload.
- **Socket and serial file locking** (`fcntl.flock`) to prevent multiple
mux instances from fighting over the same dongle or socket path.
- **500 ms keepalive** matching `PROTOCOL.md §3.4` (2× margin on the
1 s inactivity timer). Replaces the 5 s cadence from 0.x.
- **Clean ^C / SIGTERM shutdown** — per-client handler tasks are tracked
and cancelled on shutdown, so blocked `reader.read()` calls no longer
pin the event loop open.
### Fixed
### Removed
- Serial port opened without baud rate (defaulted to 9600 instead of 115200),
preventing connection to UART boards behind CP2102 bridges.
- Every v0.1 wire constant and the inline `cobs` + raw `serial.Serial`
frame codec.
- The single `pending_response: asyncio.Future` slot (fundamentally
incompatible with v1.0's per-tag concurrency).
### Test coverage
- 28 tests (up from 11):
- `test_tagmap.py` (6) — tag allocator behaviour.
- `test_intercept.py` (13) — every branch of the `decide()` matrix,
including the `ALREADY_MATCHED` / `LOCKED_MISMATCH` synthesis paths.
- `test_multiclient.py` (9) — asyncio-driven daemon dispatch tests:
concurrent tag routing, `SET_CONFIG` lock installation, TX loopback
to peer clients, lock release on disconnect, unsolicited-frame drop.
## 0.2.0 — 2026-04-08