feat: add keepalive ping, state restoration, and fix baud rate

Add keepalive ping (5s interval, 2s timeout) to detect unresponsive
dongles behind UART bridges where the serial port stays open across
ESP32 resets. On reconnect, saved radio config and RX state are
re-sent so connected clients continue working transparently.

Also fix serial port opened without baud rate (defaulted to 9600
instead of 115200), preventing connection to UART boards.
This commit is contained in:
swaits committed 2026-04-11 02:46:05 -06:00
1 parent 5c883dbcb1
commit e64ce852ba
2 files changed
+67 -2

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## Unreleased
### 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.
### Fixed
- Serial port opened without baud rate (defaulted to 9600 instead of 115200),
preventing connection to UART boards behind CP2102 bridges.
## 0.2.0 — 2026-04-08
### Features
+52 -2
View File
@@ -43,6 +43,10 @@ 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."""
@@ -179,12 +183,41 @@ class MuxDaemon:
# ── 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():
client_id, raw_cmd = await self.cmd_queue.get()
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)
@@ -368,7 +401,7 @@ class MuxDaemon:
def _open_dongle(self) -> bool:
"""Open and ping the dongle. Returns True on success."""
try:
self.ser = serial.Serial(self.serial_port, timeout=2.0, exclusive=True)
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()
@@ -409,6 +442,9 @@ class MuxDaemon:
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)
@@ -426,6 +462,15 @@ class MuxDaemon:
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())
@@ -449,6 +494,11 @@ class MuxDaemon:
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)