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
@@ -0,0 +1,200 @@
|
||||
"""Mux-level command interception (spec §13.2 / §13.4).
|
||||
|
||||
Certain commands can't be blindly forwarded when multiple clients share
|
||||
one dongle. The mux absorbs them locally and synthesizes a response so
|
||||
the dongle isn't perturbed. Relevant cases:
|
||||
|
||||
* **``SET_CONFIG``** (§13.2) — the first successful call locks the
|
||||
config; subsequent calls from *other* clients get
|
||||
``ALREADY_MATCHED`` / ``LOCKED_MISMATCH`` with ``owner = OTHER``.
|
||||
* **``RX_START`` / ``RX_STOP``** — reference-counted. The mux keeps RX
|
||||
running until the last interested client stops.
|
||||
|
||||
Mirrors ``mux-rs/src/intercept.rs``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
from donglora.commands import (
|
||||
TYPE_PING,
|
||||
TYPE_RX_START,
|
||||
TYPE_RX_STOP,
|
||||
TYPE_SET_CONFIG,
|
||||
TYPE_TX,
|
||||
)
|
||||
from donglora.errors import ErrorCode
|
||||
from donglora.events import (
|
||||
TYPE_ERR,
|
||||
TYPE_OK,
|
||||
Owner,
|
||||
SetConfigResult,
|
||||
SetConfigResultCode,
|
||||
encode_err_payload,
|
||||
)
|
||||
from donglora.modulation import Modulation, decode_modulation, encode_modulation
|
||||
from donglora_mux.session import ClientSession
|
||||
|
||||
log = logging.getLogger("donglora-mux")
|
||||
|
||||
|
||||
class DecisionKind(Enum):
|
||||
FORWARD = auto()
|
||||
SYNTHESIZE = auto()
|
||||
DROP = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""Outcome of :func:`decide` for a single command frame."""
|
||||
|
||||
kind: DecisionKind
|
||||
# Only set when kind == SYNTHESIZE.
|
||||
type_id: int | None = None
|
||||
payload: bytes | None = None
|
||||
|
||||
@classmethod
|
||||
def forward(cls) -> Decision:
|
||||
return cls(kind=DecisionKind.FORWARD)
|
||||
|
||||
@classmethod
|
||||
def synthesize(cls, type_id: int, payload: bytes) -> Decision:
|
||||
return cls(kind=DecisionKind.SYNTHESIZE, type_id=type_id, payload=payload)
|
||||
|
||||
@classmethod
|
||||
def drop(cls) -> Decision:
|
||||
return cls(kind=DecisionKind.DROP)
|
||||
|
||||
|
||||
class MuxState:
|
||||
"""Mux-level state shared across sessions."""
|
||||
|
||||
__slots__ = ("locked",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
# ``None`` when no client holds the config lock, else
|
||||
# ``(client_id, current_modulation)``.
|
||||
self.locked: tuple[int, Modulation] | None = None
|
||||
|
||||
|
||||
def rx_interest_count(sessions: dict[int, ClientSession]) -> int:
|
||||
return sum(1 for s in sessions.values() if s.rx_interested)
|
||||
|
||||
|
||||
def decide(
|
||||
type_id: int,
|
||||
payload: bytes,
|
||||
client_id: int,
|
||||
state: MuxState,
|
||||
sessions: dict[int, ClientSession],
|
||||
) -> Decision:
|
||||
"""Decide what to do with an incoming client command.
|
||||
|
||||
Returns :attr:`Decision.forward` to pass it to the dongle (after the
|
||||
caller rewrites the tag), :meth:`Decision.synthesize` to reply
|
||||
directly to the client without touching the dongle, or
|
||||
:meth:`Decision.drop` to silently swallow (reserved).
|
||||
|
||||
Unknown commands forward: the firmware returns ``ERR(EUNKNOWN_CMD)``
|
||||
with the echoed tag, which keeps the mux forward-compatible with
|
||||
minor-version command additions.
|
||||
"""
|
||||
if type_id == TYPE_SET_CONFIG:
|
||||
# Parse the modulation once for the lock comparison. A malformed
|
||||
# payload goes through and lets the firmware reject it.
|
||||
try:
|
||||
requested = decode_modulation(payload)
|
||||
except Exception:
|
||||
return Decision.forward()
|
||||
return _decide_set_config(requested, client_id, state, sessions)
|
||||
if type_id == TYPE_RX_START:
|
||||
return _decide_rx_start(client_id, sessions)
|
||||
if type_id == TYPE_RX_STOP:
|
||||
return _decide_rx_stop(client_id, sessions)
|
||||
# PING, GET_INFO, TX: forward unchanged.
|
||||
_ = (TYPE_PING, TYPE_TX) # referenced for parity with mux-rs pattern
|
||||
return Decision.forward()
|
||||
|
||||
|
||||
def _decide_set_config(
|
||||
requested: Modulation,
|
||||
client_id: int,
|
||||
state: MuxState,
|
||||
sessions: dict[int, ClientSession],
|
||||
) -> Decision:
|
||||
# Single client: unconditionally forward (scanner mode — a lone
|
||||
# client can re-tune freely).
|
||||
if len(sessions) <= 1:
|
||||
return Decision.forward()
|
||||
|
||||
if state.locked is None:
|
||||
# First SET_CONFIG with multiple clients — forward; the daemon
|
||||
# installs the lock once APPLIED comes back.
|
||||
return Decision.forward()
|
||||
|
||||
owner, current = state.locked
|
||||
if owner == client_id:
|
||||
# Lock owner is reconfiguring — forward; daemon updates lock on APPLIED.
|
||||
return Decision.forward()
|
||||
|
||||
# Another client holds the lock. Synthesize an OK with the
|
||||
# appropriate result code.
|
||||
matches = _modulations_match(requested, current)
|
||||
code = SetConfigResultCode.ALREADY_MATCHED if matches else SetConfigResultCode.LOCKED_MISMATCH
|
||||
result = SetConfigResult(result=code, owner=Owner.OTHER, current=current)
|
||||
label = sessions[client_id].label if client_id in sessions else f"id-{client_id}"
|
||||
log.debug("%s: SET_CONFIG %s (owner=OTHER) — synthesized", label, code.name)
|
||||
return Decision.synthesize(type_id=TYPE_OK, payload=result.encode())
|
||||
|
||||
|
||||
def _decide_rx_start(client_id: int, sessions: dict[int, ClientSession]) -> Decision:
|
||||
client = sessions.get(client_id)
|
||||
if client is None:
|
||||
return Decision.forward()
|
||||
if client.rx_interested:
|
||||
# Already in RX — synthesize empty OK.
|
||||
return Decision.synthesize(type_id=TYPE_OK, payload=b"")
|
||||
if rx_interest_count(sessions) > 0:
|
||||
# Others already have RX running on the dongle; just mark this
|
||||
# client interested and synthesize OK.
|
||||
client.rx_interested = True
|
||||
return Decision.synthesize(type_id=TYPE_OK, payload=b"")
|
||||
# First interested client — turn RX on at the dongle.
|
||||
return Decision.forward()
|
||||
|
||||
|
||||
def _decide_rx_stop(client_id: int, sessions: dict[int, ClientSession]) -> Decision:
|
||||
client = sessions.get(client_id)
|
||||
if client is None:
|
||||
return Decision.forward()
|
||||
if not client.rx_interested:
|
||||
# Wasn't listening — nothing to do.
|
||||
return Decision.synthesize(type_id=TYPE_OK, payload=b"")
|
||||
client.rx_interested = False
|
||||
if rx_interest_count(sessions) > 0:
|
||||
# Others still interested — don't stop RX.
|
||||
return Decision.synthesize(type_id=TYPE_OK, payload=b"")
|
||||
# Last interested client — forward so the dongle actually stops.
|
||||
return Decision.forward()
|
||||
|
||||
|
||||
def _modulations_match(a: Modulation, b: Modulation) -> bool:
|
||||
"""Byte-wise equality on the encoded wire form.
|
||||
|
||||
Spec definition of "matches" is "same params on the wire" — this
|
||||
dodges any Python-level equality differences between dataclass
|
||||
instances that share the same wire representation but differ in
|
||||
default-valued fields.
|
||||
"""
|
||||
try:
|
||||
return encode_modulation(a) == encode_modulation(b)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def synthesize_err(code: ErrorCode | int) -> tuple[int, bytes]:
|
||||
"""Build an ``ERR(code)`` synthesis payload (type_id + payload bytes)."""
|
||||
return (TYPE_ERR, encode_err_payload(code))
|
||||
Reference in new issue
Block a user