Files
mux-rs/src/intercept.rs
T
swaits 903729ccbe feat: upgrade to donglora-client 0.2, fix org URLs, publish-ready
- Upgrade donglora-client from 0.1 (monorepo path dep) to 0.2 (crates.io)
- Import wire-level constants from donglora_client::protocol::*
- Fix repository URL to github.com/donglora/mux-rs
- Add homepage, rust-version, exclude to Cargo.toml
- Fix README (remove monorepo paths, add install instructions)
- Allow clippy unwrap in test module
2026-04-07 16:14:50 -06:00

270 lines
9.5 KiB
Rust

//! Smart multiplexing: SetConfig locking, StartRx/StopRx reference counting.
//!
//! The mux intercepts certain commands to avoid redundant dongle operations
//! when multiple clients are connected. Intercepted commands get synthetic
//! responses without hitting the physical dongle.
use std::collections::HashMap;
use donglora_client::protocol::{
CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE, RESP_TAG_ERROR,
RESP_TAG_OK,
};
use tracing::{debug, warn};
use crate::session::ClientSession;
/// Mux-level state for interception logic.
pub struct MuxState {
/// Raw 13-byte RadioConfig, locked once set by any client with others connected.
pub locked_config: Option<[u8; RADIO_CONFIG_SIZE]>,
}
impl MuxState {
pub fn new() -> Self {
Self { locked_config: None }
}
}
/// Check if a command should be intercepted (synthetic response) vs forwarded to the dongle.
///
/// Returns `Some(response_bytes)` if intercepted, `None` to forward normally.
pub fn maybe_intercept(
raw_cmd: &[u8],
client_id: u64,
sessions: &mut HashMap<u64, ClientSession>,
locked_config: &Option<[u8; RADIO_CONFIG_SIZE]>,
) -> Option<Vec<u8>> {
let cmd_tag = *raw_cmd.first()?;
match cmd_tag {
CMD_TAG_SET_CONFIG => intercept_set_config(raw_cmd, client_id, sessions, locked_config),
CMD_TAG_START_RX => intercept_start_rx(client_id, sessions),
CMD_TAG_STOP_RX => intercept_stop_rx(client_id, sessions),
_ => None,
}
}
fn intercept_set_config(
raw_cmd: &[u8],
client_id: u64,
sessions: &HashMap<u64, ClientSession>,
locked_config: &Option<[u8; RADIO_CONFIG_SIZE]>,
) -> Option<Vec<u8>> {
let config_bytes = raw_cmd.get(1..1 + RADIO_CONFIG_SIZE)?;
// Single client — allow free config changes (scanner mode)
if sessions.len() <= 1 {
return None;
}
// First SetConfig with multiple clients — forward and lock on success
let Some(locked) = locked_config else {
return None;
};
// Same config — reply Ok without hitting the dongle
if config_bytes == locked.as_slice() {
let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
debug!("{label}: SetConfig matches locked config — Ok");
return Some(vec![RESP_TAG_OK]);
}
// Different config — reject
let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
warn!("{label}: SetConfig rejected (conflicts with locked config)");
Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG])
}
fn intercept_start_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?;
// Already interested — no-op
if client.rx_interested {
return Some(vec![RESP_TAG_OK]);
}
// Others already receiving — just mark this client
if rx_interest_count(sessions) > 0 {
if let Some(c) = sessions.get_mut(&client_id) {
c.rx_interested = true;
}
return Some(vec![RESP_TAG_OK]);
}
// First interested client — forward to dongle
None
}
fn intercept_stop_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?;
// Wasn't interested — no-op
if !client.rx_interested {
return Some(vec![RESP_TAG_OK]);
}
// Mark as not interested
if let Some(c) = sessions.get_mut(&client_id) {
c.rx_interested = false;
}
// Others still interested — don't stop the dongle
if rx_interest_count(sessions) > 0 {
return Some(vec![RESP_TAG_OK]);
}
// Last interested client — forward StopRx to dongle
None
}
/// Count how many sessions have rx_interested set.
pub fn rx_interest_count(sessions: &HashMap<u64, ClientSession>) -> usize {
sessions.values().filter(|s| s.rx_interested).count()
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn make_sessions(n: usize) -> HashMap<u64, ClientSession> {
let mut map = HashMap::new();
for _ in 0..n {
let (session, _rx) = ClientSession::new();
let id = session.id;
map.insert(id, session);
}
map
}
fn first_id(sessions: &HashMap<u64, ClientSession>) -> u64 {
*sessions.keys().min().unwrap()
}
fn second_id(sessions: &HashMap<u64, ClientSession>) -> u64 {
let mut ids: Vec<u64> = sessions.keys().copied().collect();
ids.sort();
ids[1]
}
// ── SetConfig ──────────────────────────────���───────────────────
#[test]
fn set_config_single_client_forwards() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
let cmd = [CMD_TAG_SET_CONFIG, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
assert!(maybe_intercept(&cmd, id, &mut sessions, &locked).is_none());
}
#[test]
fn set_config_first_with_multiple_forwards() {
let mut sessions = make_sessions(2);
let locked = None;
let id = first_id(&sessions);
let cmd = [CMD_TAG_SET_CONFIG, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
assert!(maybe_intercept(&cmd, id, &mut sessions, &locked).is_none());
}
#[test]
fn set_config_matching_returns_ok() {
let mut sessions = make_sessions(2);
let config = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
let locked = Some(config);
let id = first_id(&sessions);
let mut cmd = vec![CMD_TAG_SET_CONFIG];
cmd.extend_from_slice(&config);
assert_eq!(maybe_intercept(&cmd, id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
}
#[test]
fn set_config_conflicting_returns_error() {
let mut sessions = make_sessions(2);
let locked = Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
let id = first_id(&sessions);
let mut cmd = vec![CMD_TAG_SET_CONFIG];
cmd.extend_from_slice(&[99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert_eq!(maybe_intercept(&cmd, id, &mut sessions, &locked), Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]));
}
// ── StartRx ────────────────────────────────────────────────────
#[test]
fn start_rx_first_client_forwards() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
assert!(maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked).is_none());
}
#[test]
fn start_rx_already_interested_returns_ok() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
sessions.get_mut(&id).unwrap().rx_interested = true;
assert_eq!(maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
}
#[test]
fn start_rx_others_interested_marks_and_returns_ok() {
let mut sessions = make_sessions(2);
let locked = None;
let id1 = first_id(&sessions);
let id2 = second_id(&sessions);
sessions.get_mut(&id1).unwrap().rx_interested = true;
let result = maybe_intercept(&[CMD_TAG_START_RX], id2, &mut sessions, &locked);
assert_eq!(result, Some(vec![RESP_TAG_OK]));
assert!(sessions.get(&id2).unwrap().rx_interested);
}
// ── StopRx ─────────────────────────────────────────────────────
#[test]
fn stop_rx_not_interested_returns_ok() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
assert_eq!(maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
}
#[test]
fn stop_rx_others_remain_returns_ok() {
let mut sessions = make_sessions(2);
let locked = None;
let id1 = first_id(&sessions);
let id2 = second_id(&sessions);
sessions.get_mut(&id1).unwrap().rx_interested = true;
sessions.get_mut(&id2).unwrap().rx_interested = true;
let result = maybe_intercept(&[CMD_TAG_STOP_RX], id1, &mut sessions, &locked);
assert_eq!(result, Some(vec![RESP_TAG_OK]));
assert!(!sessions.get(&id1).unwrap().rx_interested);
}
#[test]
fn stop_rx_last_interested_forwards() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
sessions.get_mut(&id).unwrap().rx_interested = true;
assert!(maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked).is_none());
assert!(!sessions.get(&id).unwrap().rx_interested);
}
// ── Other commands ─────────────────────────────────────────────
#[test]
fn other_commands_not_intercepted() {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
for tag in [0u8, 1, 5, 6, 7, 8] {
assert!(maybe_intercept(&[tag], id, &mut sessions, &locked).is_none());
}
}
}