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
+48
-521
@@ -1,531 +1,70 @@
|
||||
"""DongLoRa USB Multiplexer — share one dongle with multiple applications.
|
||||
|
||||
Owns the USB serial connection and exposes a Unix domain socket that speaks
|
||||
the same COBS-framed protocol. Clients connect via the socket (or just call
|
||||
dl.connect() which auto-detects the mux) and get:
|
||||
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:
|
||||
|
||||
• Solicited responses routed back to the commanding client
|
||||
• RxPacket frames broadcast to ALL connected clients
|
||||
• StartRx/StopRx reference-counted so the radio stays in RX as long as
|
||||
anyone wants it
|
||||
• SetConfig locked once set — subsequent clients must use the same config
|
||||
(a single client can change config freely, like a scanner)
|
||||
* **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::
|
||||
|
||||
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 contextlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
from cobs import cobs
|
||||
|
||||
import donglora as dl
|
||||
from donglora_mux.daemon import MuxDaemon
|
||||
|
||||
__all__ = ["MuxDaemon", "main"]
|
||||
|
||||
log = logging.getLogger("donglora-mux")
|
||||
|
||||
# ── Protocol constants ───────────────────────────────────────────
|
||||
|
||||
TAG_RXPACKET = 2
|
||||
TAG_OK = 4
|
||||
TAG_ERROR = 5
|
||||
CMD_TAG_SET_CONFIG = 2
|
||||
CMD_TAG_START_RX = 3
|
||||
CMD_TAG_STOP_RX = 4
|
||||
|
||||
ERROR_INVALID_CONFIG = 0
|
||||
|
||||
# Keepalive: ping every 5s of idle, declare lost after 2s timeout
|
||||
KEEPALIVE_INTERVAL = 5.0
|
||||
KEEPALIVE_TIMEOUT = 2.0
|
||||
|
||||
|
||||
def default_socket_path() -> str:
|
||||
"""Resolve the mux socket path in priority order."""
|
||||
env = os.environ.get("DONGLORA_MUX")
|
||||
if env:
|
||||
return env
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if xdg:
|
||||
return os.path.join(xdg, "donglora", "mux.sock")
|
||||
return "/tmp/donglora-mux.sock"
|
||||
|
||||
|
||||
# ── Per-client state ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class Client:
|
||||
"""Tracks one connected client."""
|
||||
|
||||
_next_id = 0
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self.id = Client._next_id
|
||||
Client._next_id += 1
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.rx_interested = False # has this client called StartRx?
|
||||
self.send_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=256)
|
||||
self._sender_task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"client-{self.id}"
|
||||
|
||||
def start_sender(self) -> None:
|
||||
self._sender_task = asyncio.create_task(self._sender(), name=f"{self.label}-sender")
|
||||
|
||||
async def _sender(self) -> None:
|
||||
"""Drain send_queue → socket. Runs until cancelled or write fails."""
|
||||
try:
|
||||
while True:
|
||||
frame = await self.send_queue.get()
|
||||
self.writer.write(frame)
|
||||
await self.writer.drain()
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
|
||||
async def enqueue(self, cobs_frame: bytes) -> None:
|
||||
"""Enqueue a COBS frame for sending. Drops oldest RxPacket on overflow."""
|
||||
try:
|
||||
self.send_queue.put_nowait(cobs_frame)
|
||||
except asyncio.QueueFull:
|
||||
# Drop oldest to make room (best-effort for slow clients)
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
self.send_queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
self.send_queue.put_nowait(cobs_frame)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sender_task:
|
||||
self._sender_task.cancel()
|
||||
with contextlib.suppress(Exception):
|
||||
self.writer.close()
|
||||
|
||||
|
||||
# ── Mux daemon ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MuxDaemon:
|
||||
def __init__(self, serial_port: str, socket_path: str, tcp_addr: tuple[str, int] | None = None):
|
||||
self.serial_port = serial_port
|
||||
self.socket_path = socket_path
|
||||
self.tcp_addr = tcp_addr
|
||||
self.ser: serial.Serial | None = None
|
||||
self.clients: dict[int, Client] = {}
|
||||
self.cmd_queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue()
|
||||
self.pending_response: asyncio.Future[bytes] | None = None
|
||||
self.locked_config: bytes | None = None # raw RadioConfig bytes (13 bytes) once set
|
||||
self._shutdown = asyncio.Event()
|
||||
|
||||
# ── Serial I/O (blocking, run in threads) ────────────────────
|
||||
|
||||
def _serial_read_frame(self) -> bytes | None:
|
||||
"""Blocking read of one COBS frame from dongle."""
|
||||
assert self.ser is not None
|
||||
return dl.read_frame(self.ser)
|
||||
|
||||
def _serial_write(self, data: bytes) -> None:
|
||||
"""Blocking write to dongle."""
|
||||
assert self.ser is not None
|
||||
self.ser.write(data)
|
||||
self.ser.flush()
|
||||
|
||||
# ── Dongle reader task ───────────────────────────────────────
|
||||
|
||||
async def dongle_reader(self) -> None:
|
||||
"""Read frames from the dongle, dispatch solicited vs broadcast."""
|
||||
consecutive_errors = 0
|
||||
max_retries = 3
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
raw = await asyncio.to_thread(self._serial_read_frame)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= max_retries:
|
||||
log.error(
|
||||
"Dongle read error (giving up after %d consecutive failures): %s",
|
||||
consecutive_errors,
|
||||
e,
|
||||
)
|
||||
self._handle_dongle_disconnect()
|
||||
return
|
||||
log.warning("Dongle read glitch (%d/%d): %s", consecutive_errors, max_retries, e)
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
if raw is None:
|
||||
continue
|
||||
|
||||
tag = raw[0]
|
||||
cobs_frame = dl.cobs_encode(raw)
|
||||
|
||||
if tag == TAG_RXPACKET:
|
||||
# Broadcast to all clients
|
||||
for client in list(self.clients.values()):
|
||||
await client.enqueue(cobs_frame)
|
||||
else:
|
||||
# Solicited response — resolve the pending future
|
||||
if self.pending_response and not self.pending_response.done():
|
||||
self.pending_response.set_result(raw)
|
||||
else:
|
||||
log.warning("Unsolicited non-RxPacket response (tag %d) — dropped", tag)
|
||||
|
||||
# ── Dongle writer task ───────────────────────────────────────
|
||||
|
||||
async def _keepalive_ping(self) -> bool:
|
||||
"""Send a Ping and wait for Pong. Returns True if dongle responded."""
|
||||
loop = asyncio.get_running_loop()
|
||||
self.pending_response = loop.create_future()
|
||||
|
||||
try:
|
||||
ping_frame = dl.cobs_encode(dl.encode_command("Ping"))
|
||||
await asyncio.to_thread(self._serial_write, ping_frame)
|
||||
except (serial.SerialException, OSError):
|
||||
self.pending_response = None
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self.pending_response, timeout=KEEPALIVE_TIMEOUT)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
self.pending_response = None
|
||||
return False
|
||||
|
||||
async def dongle_writer(self) -> None:
|
||||
"""Pull commands from queue, send one at a time, route responses."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
client_id, raw_cmd = await asyncio.wait_for(
|
||||
self.cmd_queue.get(), timeout=KEEPALIVE_INTERVAL
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.debug("keepalive ping")
|
||||
if not await self._keepalive_ping():
|
||||
log.error("Keepalive ping failed - dongle lost")
|
||||
self._handle_dongle_disconnect()
|
||||
return
|
||||
continue
|
||||
|
||||
# Check for StartRx/StopRx ref-counting intercept
|
||||
intercepted = await self._maybe_intercept(client_id, raw_cmd)
|
||||
if intercepted is not None:
|
||||
# Send the synthetic response to the client
|
||||
if client_id in self.clients:
|
||||
await self.clients[client_id].enqueue(dl.cobs_encode(intercepted))
|
||||
continue
|
||||
|
||||
# Create future for the solicited response
|
||||
self.pending_response = loop.create_future()
|
||||
|
||||
# Send COBS-encoded command to dongle
|
||||
cobs_frame = dl.cobs_encode(raw_cmd)
|
||||
try:
|
||||
await asyncio.to_thread(self._serial_write, cobs_frame)
|
||||
except (serial.SerialException, OSError) as e:
|
||||
log.error("Dongle write error: %s", e)
|
||||
self.pending_response.set_result(bytes([5, 1])) # Error(RadioBusy)
|
||||
self._handle_dongle_disconnect()
|
||||
return
|
||||
|
||||
# Wait for dongle_reader to resolve the future
|
||||
try:
|
||||
response = await asyncio.wait_for(self.pending_response, timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Dongle response timeout")
|
||||
response = bytes([5, 1]) # Error(RadioBusy)
|
||||
finally:
|
||||
self.pending_response = None
|
||||
|
||||
# Track state on successful forward
|
||||
cmd_tag = raw_cmd[0]
|
||||
resp_tag = response[0] if response else 0xFF
|
||||
if cmd_tag == CMD_TAG_SET_CONFIG and resp_tag == TAG_OK:
|
||||
self.locked_config = raw_cmd[1:]
|
||||
log.info("Radio config locked: %s", self.locked_config.hex())
|
||||
elif cmd_tag == CMD_TAG_START_RX and resp_tag == TAG_OK:
|
||||
if client_id in self.clients:
|
||||
self.clients[client_id].rx_interested = True
|
||||
elif cmd_tag == CMD_TAG_STOP_RX and resp_tag == TAG_OK:
|
||||
if client_id in self.clients:
|
||||
self.clients[client_id].rx_interested = False
|
||||
|
||||
# Route response to commanding client (if still connected)
|
||||
if client_id in self.clients:
|
||||
await self.clients[client_id].enqueue(dl.cobs_encode(response))
|
||||
|
||||
# ── StartRx / StopRx reference counting ──────────────────────
|
||||
|
||||
def _rx_interest_count(self) -> int:
|
||||
return sum(1 for c in self.clients.values() if c.rx_interested)
|
||||
|
||||
async def _maybe_intercept(self, client_id: int, raw_cmd: bytes) -> bytes | None:
|
||||
"""Intercept SetConfig/StartRx/StopRx for smart multiplexing.
|
||||
|
||||
Returns a synthetic response if intercepted, None to forward normally.
|
||||
"""
|
||||
if not raw_cmd:
|
||||
return None
|
||||
cmd_tag = raw_cmd[0]
|
||||
client = self.clients.get(client_id)
|
||||
|
||||
if cmd_tag == CMD_TAG_SET_CONFIG:
|
||||
config_bytes = raw_cmd[1:] # 13-byte RadioConfig
|
||||
if len(self.clients) <= 1:
|
||||
# Single client — allow free config changes (scanner mode)
|
||||
# Forward to dongle, update locked_config on success
|
||||
return None
|
||||
if self.locked_config is None:
|
||||
# First SetConfig with multiple clients — forward and lock
|
||||
return None
|
||||
if config_bytes == self.locked_config:
|
||||
# Same config — reply Ok without hitting the dongle
|
||||
log.debug(
|
||||
"%s: SetConfig matches locked config — Ok",
|
||||
client.label if client else f"id-{client_id}",
|
||||
)
|
||||
return bytes([TAG_OK])
|
||||
# Different config — reject
|
||||
log.warning(
|
||||
"%s: SetConfig rejected (conflicts with locked config)",
|
||||
client.label if client else f"id-{client_id}",
|
||||
)
|
||||
return bytes([TAG_ERROR, ERROR_INVALID_CONFIG])
|
||||
|
||||
if cmd_tag == CMD_TAG_START_RX:
|
||||
if client and client.rx_interested:
|
||||
# Already interested — no-op, reply Ok
|
||||
return bytes([TAG_OK])
|
||||
if self._rx_interest_count() > 0:
|
||||
# Someone else is already receiving — just mark this client, reply Ok
|
||||
if client:
|
||||
client.rx_interested = True
|
||||
return bytes([TAG_OK])
|
||||
# First interested client — forward to dongle (return None)
|
||||
return None
|
||||
|
||||
if cmd_tag == CMD_TAG_STOP_RX:
|
||||
if client and not client.rx_interested:
|
||||
# Wasn't interested — no-op, reply Ok
|
||||
return bytes([TAG_OK])
|
||||
if client:
|
||||
client.rx_interested = False
|
||||
if self._rx_interest_count() > 0:
|
||||
# Others still interested — don't stop, reply Ok
|
||||
return bytes([TAG_OK])
|
||||
# Last interested client — forward to dongle (return None)
|
||||
return None
|
||||
|
||||
def _parse_tcp_addr(value: str) -> tuple[str, int] | None:
|
||||
if value.lower() == "none":
|
||||
return None
|
||||
|
||||
# ── Client handling ──────────────────────────────────────────
|
||||
|
||||
async def client_handler(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
client = Client(reader, writer)
|
||||
self.clients[client.id] = client
|
||||
client.start_sender()
|
||||
log.info("%s connected (%d total)", client.label, len(self.clients))
|
||||
|
||||
try:
|
||||
buf = b""
|
||||
while True:
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
buf += data
|
||||
# Extract complete COBS frames (delimited by 0x00)
|
||||
while b"\x00" in buf:
|
||||
idx = buf.index(b"\x00")
|
||||
encoded = buf[:idx]
|
||||
buf = buf[idx + 1 :]
|
||||
if not encoded:
|
||||
continue
|
||||
try:
|
||||
raw_cmd = cobs.decode(encoded)
|
||||
except cobs.DecodeError:
|
||||
log.warning("%s: bad COBS frame — skipped", client.label)
|
||||
continue
|
||||
await self.cmd_queue.put((client.id, raw_cmd))
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
await self._remove_client(client)
|
||||
|
||||
async def _remove_client(self, client: Client) -> None:
|
||||
"""Clean up after a disconnected client."""
|
||||
was_interested = client.rx_interested
|
||||
client.close()
|
||||
self.clients.pop(client.id, None)
|
||||
log.info("%s disconnected (%d remain)", client.label, len(self.clients))
|
||||
|
||||
# If this client had RX interest and was the last one, stop RX
|
||||
if was_interested and self._rx_interest_count() == 0:
|
||||
log.info("Last RX-interested client gone — sending StopRx")
|
||||
await self.cmd_queue.put((-1, bytes([CMD_TAG_STOP_RX])))
|
||||
|
||||
# Reset locked config when all clients are gone
|
||||
if not self.clients and self.locked_config is not None:
|
||||
log.info("All clients gone — config lock released")
|
||||
self.locked_config = None
|
||||
|
||||
# ── Dongle disconnect ────────────────────────────────────────
|
||||
|
||||
_dongle_lost: asyncio.Event
|
||||
|
||||
def _handle_dongle_disconnect(self) -> None:
|
||||
log.error("Dongle disconnected — will reconnect")
|
||||
if self.pending_response and not self.pending_response.done():
|
||||
self.pending_response.set_result(bytes([5, 1])) # Error(RadioBusy)
|
||||
if self.ser:
|
||||
with contextlib.suppress(Exception):
|
||||
self.ser.close()
|
||||
self.ser = None
|
||||
self._dongle_lost.set()
|
||||
|
||||
# ── Dongle connection ──────────────────────────────────────────
|
||||
|
||||
def _open_dongle(self) -> bool:
|
||||
"""Open and ping the dongle. Returns True on success."""
|
||||
try:
|
||||
self.ser = serial.Serial(self.serial_port, 115200, timeout=2.0, exclusive=True)
|
||||
self.ser.reset_input_buffer()
|
||||
self._serial_write(dl.cobs_encode(dl.encode_command("Ping")))
|
||||
pong = self._serial_read_frame()
|
||||
if not pong or pong[0] != 0:
|
||||
log.warning("Dongle did not respond to Ping")
|
||||
self.ser.close()
|
||||
self.ser = None
|
||||
return False
|
||||
log.info("Dongle responded to Ping")
|
||||
return True
|
||||
except (serial.SerialException, OSError) as e:
|
||||
log.warning("Could not open dongle: %s", e)
|
||||
self.ser = None
|
||||
return False
|
||||
|
||||
# ── Main entry ───────────────────────────────────────────────
|
||||
|
||||
async def run(self) -> None:
|
||||
self._dongle_lost = asyncio.Event()
|
||||
|
||||
# Prepare socket path
|
||||
sock_path = Path(self.socket_path)
|
||||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if sock_path.exists():
|
||||
sock_path.unlink()
|
||||
|
||||
# Start servers (these stay up for the lifetime of the daemon)
|
||||
server = await asyncio.start_unix_server(self.client_handler, path=str(sock_path))
|
||||
log.info("Unix socket listening on %s", self.socket_path)
|
||||
|
||||
tcp_server = None
|
||||
if self.tcp_addr:
|
||||
tcp_server = await asyncio.start_server(
|
||||
self.client_handler,
|
||||
self.tcp_addr[0],
|
||||
self.tcp_addr[1],
|
||||
)
|
||||
log.info("TCP listening on %s:%d", *self.tcp_addr)
|
||||
|
||||
# Connect/reconnect loop — servers stay up, dongle comes and goes
|
||||
saved_config: bytes | None = None
|
||||
had_rx_interest = False
|
||||
|
||||
try:
|
||||
while not self._shutdown.is_set():
|
||||
# Connect to dongle (retry until it appears)
|
||||
log.info("Opening dongle on %s", self.serial_port)
|
||||
while not self._shutdown.is_set():
|
||||
if await asyncio.to_thread(self._open_dongle):
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if self._shutdown.is_set():
|
||||
break
|
||||
|
||||
# Run dongle reader/writer until dongle is lost
|
||||
self._dongle_lost.clear()
|
||||
reader_task = asyncio.create_task(self.dongle_reader(), name="dongle-reader")
|
||||
writer_task = asyncio.create_task(self.dongle_writer(), name="dongle-writer")
|
||||
|
||||
# Re-establish radio state from previous session
|
||||
if saved_config is not None:
|
||||
log.info("Restoring radio config after reconnect")
|
||||
await self.cmd_queue.put((-1, bytes([CMD_TAG_SET_CONFIG]) + saved_config))
|
||||
if had_rx_interest:
|
||||
await self.cmd_queue.put((-1, bytes([CMD_TAG_START_RX])))
|
||||
saved_config = None
|
||||
had_rx_interest = False
|
||||
|
||||
# Wait for dongle loss or shutdown
|
||||
shutdown_task = asyncio.create_task(self._shutdown.wait())
|
||||
lost_task = asyncio.create_task(self._dongle_lost.wait())
|
||||
await asyncio.wait(
|
||||
[shutdown_task, lost_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
shutdown_task.cancel()
|
||||
lost_task.cancel()
|
||||
|
||||
# Tear down dongle tasks
|
||||
reader_task.cancel()
|
||||
writer_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await asyncio.gather(reader_task, writer_task, return_exceptions=True)
|
||||
|
||||
# Drain any pending commands with errors
|
||||
while not self.cmd_queue.empty():
|
||||
try:
|
||||
self.cmd_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
# Save radio state for restoration after reconnect
|
||||
saved_config = self.locked_config
|
||||
had_rx_interest = self._rx_interest_count() > 0
|
||||
self.locked_config = None
|
||||
|
||||
if not self._shutdown.is_set():
|
||||
log.info("Reconnecting in 2 seconds...")
|
||||
await asyncio.sleep(2)
|
||||
finally:
|
||||
# Cleanup
|
||||
log.info("Shutting down...")
|
||||
for client in list(self.clients.values()):
|
||||
client.close()
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
if tcp_server:
|
||||
tcp_server.close()
|
||||
await tcp_server.wait_closed()
|
||||
if self.ser:
|
||||
self.ser.close()
|
||||
if sock_path.exists():
|
||||
sock_path.unlink()
|
||||
log.info("Stopped.")
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────
|
||||
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")
|
||||
parser.add_argument("--port", "-p", default=None, help="Serial port (auto-detect if omitted)")
|
||||
parser.add_argument("--socket", "-s", default=None, help="Unix socket path")
|
||||
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",
|
||||
@@ -544,29 +83,17 @@ def main() -> None:
|
||||
|
||||
port = args.port or dl.find_port()
|
||||
if not port:
|
||||
log.info("Waiting for DongLoRa device...")
|
||||
log.info("waiting for DongLoRa device...")
|
||||
port = dl.wait_for_device()
|
||||
|
||||
socket_path = args.socket or default_socket_path()
|
||||
|
||||
tcp_addr = None
|
||||
if args.tcp and args.tcp.lower() != "none":
|
||||
if ":" in args.tcp:
|
||||
host, _, p = args.tcp.rpartition(":")
|
||||
tcp_addr = (host, int(p))
|
||||
else:
|
||||
tcp_addr = ("0.0.0.0", int(args.tcp))
|
||||
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()
|
||||
|
||||
def _signal_handler() -> None:
|
||||
daemon._shutdown.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
loop.add_signal_handler(sig, daemon.request_shutdown)
|
||||
try:
|
||||
loop.run_until_complete(daemon.run())
|
||||
finally:
|
||||
|
||||
Reference in new issue
Block a user