feat: add keepalive ping and state restoration after dongle reconnect
UART boards (CP2102 bridge) keep the serial port alive across ESP32 resets, so the mux never detected the board rebooting. Now the writer sends a keepalive Ping every 5s of idle and declares the dongle lost after a 2s timeout. On reconnect, saved radio config and RX state are re-sent to the dongle so connected clients continue working without re-establishing state.
This commit is contained in:
2 files changed
+86
-9
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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.
|
||||||
|
|
||||||
## 0.2.2 — 2026-04-08
|
## 0.2.2 — 2026-04-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+76
-9
@@ -40,6 +40,12 @@ const MAX_CONSECUTIVE_ERRORS: u32 = 3;
|
|||||||
/// Sentinel client ID for synthetic commands (e.g. auto StopRx).
|
/// Sentinel client ID for synthetic commands (e.g. auto StopRx).
|
||||||
const SYNTHETIC_CLIENT_ID: u64 = u64::MAX;
|
const SYNTHETIC_CLIENT_ID: u64 = u64::MAX;
|
||||||
|
|
||||||
|
/// Keepalive ping interval: if no commands flow for this long, ping the dongle.
|
||||||
|
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long to wait for a keepalive Pong before declaring the dongle lost.
|
||||||
|
const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// Shared mux state, protected by async mutex.
|
/// Shared mux state, protected by async mutex.
|
||||||
type SharedState = Arc<Mutex<MuxInner>>;
|
type SharedState = Arc<Mutex<MuxInner>>;
|
||||||
|
|
||||||
@@ -146,10 +152,13 @@ impl MuxDaemon {
|
|||||||
async fn reconnect_loop(
|
async fn reconnect_loop(
|
||||||
&self,
|
&self,
|
||||||
state: SharedState,
|
state: SharedState,
|
||||||
_cmd_tx: mpsc::Sender<(u64, Vec<u8>)>, // kept alive to prevent channel close
|
cmd_tx: mpsc::Sender<(u64, Vec<u8>)>,
|
||||||
cmd_rx: SharedCmdRx,
|
cmd_rx: SharedCmdRx,
|
||||||
pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>,
|
pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>,
|
||||||
) {
|
) {
|
||||||
|
let mut saved_config: Option<[u8; RADIO_CONFIG_SIZE]> = None;
|
||||||
|
let mut had_rx_interest = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if self.shutdown.is_cancelled() {
|
if self.shutdown.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
@@ -194,6 +203,17 @@ impl MuxDaemon {
|
|||||||
self.shutdown.clone(),
|
self.shutdown.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Re-establish radio state from previous session
|
||||||
|
if let Some(config) = saved_config.take() {
|
||||||
|
info!("restoring radio config after reconnect");
|
||||||
|
let mut cmd = vec![CMD_TAG_SET_CONFIG];
|
||||||
|
cmd.extend_from_slice(&config);
|
||||||
|
let _ = cmd_tx.send((SYNTHETIC_CLIENT_ID, cmd)).await;
|
||||||
|
if had_rx_interest {
|
||||||
|
let _ = cmd_tx.send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_START_RX])).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for dongle loss or shutdown
|
// Wait for dongle loss or shutdown
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
() = self.shutdown.cancelled() => {},
|
() = self.shutdown.cancelled() => {},
|
||||||
@@ -211,6 +231,13 @@ impl MuxDaemon {
|
|||||||
while rx.try_recv().is_ok() {}
|
while rx.try_recv().is_ok() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save radio state for restoration after reconnect
|
||||||
|
{
|
||||||
|
let mut inner = state.lock().await;
|
||||||
|
saved_config = inner.mux_state.locked_config.take();
|
||||||
|
had_rx_interest = intercept::rx_interest_count(&inner.sessions) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
if !self.shutdown.is_cancelled() {
|
if !self.shutdown.is_cancelled() {
|
||||||
info!("reconnecting in 2 seconds...");
|
info!("reconnecting in 2 seconds...");
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -380,6 +407,32 @@ async fn resolve_pending_with_error(pending_response: &Arc<Mutex<Option<oneshot:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a Ping and wait for Pong. Returns `true` if the dongle responded.
|
||||||
|
async fn keepalive_ping(
|
||||||
|
serial: &mut tokio::io::WriteHalf<tokio_serial::SerialStream>,
|
||||||
|
pending_response: &Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>,
|
||||||
|
) -> bool {
|
||||||
|
let (resp_tx, resp_rx) = oneshot::channel();
|
||||||
|
{
|
||||||
|
let mut slot = pending_response.lock().await;
|
||||||
|
*slot = Some(resp_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ping_frame = encode_frame(&[0]);
|
||||||
|
if AsyncWriteExt::write_all(serial, &ping_frame).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(KEEPALIVE_TIMEOUT, resp_rx).await {
|
||||||
|
Ok(Ok(_)) => true,
|
||||||
|
_ => {
|
||||||
|
let mut slot = pending_response.lock().await;
|
||||||
|
*slot = None;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn dongle_writer(
|
async fn dongle_writer(
|
||||||
mut serial: tokio::io::WriteHalf<tokio_serial::SerialStream>,
|
mut serial: tokio::io::WriteHalf<tokio_serial::SerialStream>,
|
||||||
cmd_rx: SharedCmdRx,
|
cmd_rx: SharedCmdRx,
|
||||||
@@ -388,22 +441,36 @@ async fn dongle_writer(
|
|||||||
dongle_lost: CancellationToken,
|
dongle_lost: CancellationToken,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
) {
|
) {
|
||||||
|
let keepalive = tokio::time::sleep(KEEPALIVE_INTERVAL);
|
||||||
|
tokio::pin!(keepalive);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Pull next command from queue
|
// Pull next command, or fire keepalive if idle too long
|
||||||
let (client_id, raw_cmd) = {
|
let cmd = {
|
||||||
let mut rx = cmd_rx.lock().await;
|
let mut rx = cmd_rx.lock().await;
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
cmd = rx.recv() => {
|
cmd = rx.recv() => cmd,
|
||||||
match cmd {
|
() = &mut keepalive => None,
|
||||||
Some(c) => c,
|
|
||||||
None => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
() = shutdown.cancelled() => return,
|
() = shutdown.cancelled() => return,
|
||||||
() = dongle_lost.cancelled() => return,
|
() = dongle_lost.cancelled() => return,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
keepalive.as_mut().reset(tokio::time::Instant::now() + KEEPALIVE_INTERVAL);
|
||||||
|
|
||||||
|
let (client_id, raw_cmd) = match cmd {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
debug!("keepalive ping");
|
||||||
|
if !keepalive_ping(&mut serial, &pending_response).await {
|
||||||
|
error!("keepalive ping failed - dongle lost");
|
||||||
|
dongle_lost.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check for interception
|
// Check for interception
|
||||||
let intercepted = {
|
let intercepted = {
|
||||||
let mut inner = state.lock().await;
|
let mut inner = state.lock().await;
|
||||||
|
|||||||
Reference in new issue
Block a user