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.
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
"""DongLoRa USB Multiplexer — share one dongle with multiple applications.
|
|
|
|
Owns the USB serial connection and exposes a Unix domain socket (and
|
|
optional TCP listener) that speaks the DongLoRa Protocol v2 wire format. Clients
|
|
connect and get:
|
|
|
|
* **Tag-correlated solicited responses** — concurrent commands from
|
|
multiple clients are routed back to the originator via a mux-owned
|
|
device-tag allocator (``PROTOCOL.md §13.6``).
|
|
* **`RX` fan-out** — every `RX` packet is broadcast to all clients.
|
|
* **TX loopback** (``PROTOCOL.md §13.4``) — a successful over-the-air
|
|
TX from one client is delivered to every other connected client as
|
|
an `RX` event with ``origin = LOCAL_LOOPBACK``.
|
|
* **Smart `SET_CONFIG` arbitration** (``PROTOCOL.md §13.2``) — the
|
|
first successful call locks the config; cross-client calls get
|
|
`ALREADY_MATCHED` / `LOCKED_MISMATCH` with the lock owner echoed in
|
|
``current``.
|
|
* **`RX_START` / `RX_STOP` ref-counting** — the radio stays in RX as
|
|
long as any client wants it.
|
|
|
|
Usage::
|
|
|
|
donglora-mux [--port /dev/ttyACM0] [--socket /tmp/donglora-mux.sock] [--tcp 5741]
|
|
|
|
See ``PROTOCOL.md`` for the wire protocol and the Rust sibling
|
|
``donglora-mux`` (``cargo install donglora-mux``) for a drop-in
|
|
compatible implementation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import signal
|
|
|
|
import donglora as dl
|
|
from donglora_mux.daemon import MuxDaemon
|
|
|
|
__all__ = ["MuxDaemon", "main"]
|
|
|
|
log = logging.getLogger("donglora-mux")
|
|
|
|
|
|
def _parse_tcp_addr(value: str) -> tuple[str, int] | None:
|
|
if value.lower() == "none":
|
|
return None
|
|
if ":" in value:
|
|
host, _, port_str = value.rpartition(":")
|
|
return (host, int(port_str))
|
|
return ("0.0.0.0", int(value))
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="DongLoRa USB Multiplexer (DongLoRa Protocol v2)")
|
|
parser.add_argument(
|
|
"--port",
|
|
"-p",
|
|
default=None,
|
|
help="Serial port (auto-detect by USB VID:PID if omitted)",
|
|
)
|
|
parser.add_argument(
|
|
"--socket",
|
|
"-s",
|
|
default=None,
|
|
help="Unix socket path (default: $XDG_RUNTIME_DIR/donglora/mux.sock or /tmp/donglora-mux.sock)",
|
|
)
|
|
parser.add_argument(
|
|
"--tcp",
|
|
"-t",
|
|
default="0.0.0.0:5741",
|
|
metavar="[HOST:]PORT",
|
|
help="TCP listen address (default: 0.0.0.0:5741, 'none' to disable)",
|
|
)
|
|
parser.add_argument("--verbose", "-v", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
port = args.port or dl.find_port()
|
|
if not port:
|
|
log.info("waiting for DongLoRa device...")
|
|
port = dl.wait_for_device()
|
|
|
|
socket_path = args.socket or dl.default_socket_path()
|
|
tcp_addr = _parse_tcp_addr(args.tcp)
|
|
|
|
daemon = MuxDaemon(port, socket_path, tcp_addr=tcp_addr)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
loop.add_signal_handler(sig, daemon.request_shutdown)
|
|
try:
|
|
loop.run_until_complete(daemon.run())
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|