//! Status protocol for the MMX 150 wireless dongle (reverse-engineered). //! //! The dongle rejects `GET_FEATURE`/`GET_INPUT` control transfers, so status //! queries are write-then-read exchanges over the vendor output reports. //! //! Link state: writing output report `0x3a` (`3a 09 22 01 00...`) elicits an //! input report `0x3c` (`3c 01 00 00 00 01 ...`) on the interrupt IN endpoint //! if and only if the headset is linked to the dongle. No response within the //! timeout means the headset is powered off or out of range. /// Vendor output report IDs available on the dongle. pub mod reports { /// 60-byte output/feature report. pub const OUT_60_A: u8 = 0x2a; /// 60-byte feature report. pub const FEAT_60_B: u8 = 0x2b; /// 60-byte output/feature report. pub const OUT_60_C: u8 = 0x3a; /// 60-byte feature report. pub const FEAT_60_D: u8 = 0x3b; /// 16-byte output report. pub const OUT_16_A: u8 = 0x2d; /// 16-byte output report. pub const OUT_16_B: u8 = 0x3d; } use std::io; use std::time::{Duration, Instant}; use crate::hid::Hidraw; /// Consecutive missed polls required before declaring the headset /// disconnected (guards against dropped RF packets). pub const LINK_DEBOUNCE_MISSES: u32 = 2; /// How long to wait for a link response after sending the query. const LINK_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400); /// Input report id the dongle answers with when the headset is linked. const LINK_RESPONSE_REPORT: u8 = 0x3c; /// Selector byte we query with; the response echoes it back. const LINK_SELECTOR: u8 = 0x01; /// Command prefix at the start of the query payload. const CMD_PREFIX: [u8; 2] = [0x09, 0x22]; /// Link query frame size: report id + 60-byte payload. const LINK_QUERY_LEN: usize = 61; /// Headset link state as seen through the dongle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinkState { Connected, Disconnected, } /// A confirmed change of [`LinkState`], with `initial` set when the /// transition is the daemon's first determination of the state (rather than /// a change from a previously confirmed state). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Transition { ToConnected { initial: bool }, ToDisconnected { initial: bool }, } /// Builds the link query output report (report id `0x3a` + payload). #[must_use] pub const fn link_query_frame() -> [u8; LINK_QUERY_LEN] { let mut frame = [0u8; LINK_QUERY_LEN]; frame[0] = reports::OUT_60_C; frame[1] = CMD_PREFIX[0]; frame[2] = CMD_PREFIX[1]; frame[3] = LINK_SELECTOR; frame } /// True if `data` is a link-status response report (`0x3c` echoing the /// query selector). #[must_use] pub const fn is_link_response(data: &[u8]) -> bool { matches!( data, [id, selector, ..] if *id == LINK_RESPONSE_REPORT && *selector == LINK_SELECTOR ) } /// Queries the dongle once for the headset link state. Returns /// `Ok(Disconnected)` if no response arrives within the timeout window. /// /// # Errors /// /// Propagates I/O errors from writing the query or reading the response. pub fn link_status(hid: &mut Hidraw) -> io::Result { hid.write(&link_query_frame())?; let deadline = Instant::now() .checked_add(LINK_RESPONSE_TIMEOUT) .unwrap_or_else(Instant::now); while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { match hid.read_with_timeout(remaining)? { Some(data) if is_link_response(&data) => return Ok(LinkState::Connected), Some(_) => {} None => break, } } Ok(LinkState::Disconnected) } /// Tracks link state across polls, debouncing disconnects and reporting /// transitions. /// /// State starts unknown (`None`) and is pinned by the first confirmed /// determination; `fire_initial` controls whether that first determination /// is reported as a transition. #[derive(Debug)] pub struct StateTracker { state: Option, misses: u32, fire_initial: bool, } impl StateTracker { /// Creates a tracker. See the struct docs for `fire_initial`. #[must_use] pub const fn new(fire_initial: bool) -> Self { Self { state: None, misses: 0, fire_initial, } } /// Current confirmed state, `None` until the first determination. #[must_use] pub const fn state(&self) -> Option { self.state } /// Feed one poll result (`true` = link response seen) and get any /// confirmed transition. #[must_use] pub const fn update(&mut self, link_up: bool) -> Option { if link_up { self.misses = 0; if matches!(self.state, Some(LinkState::Connected)) { return None; } self.settle(LinkState::Connected) } else { self.misses = self.misses.saturating_add(1); if self.misses < LINK_DEBOUNCE_MISSES || matches!(self.state, Some(LinkState::Disconnected)) { return None; } self.settle(LinkState::Disconnected) } } /// Pin the state, producing the transition unless it is the first /// determination and initial hooks are suppressed. const fn settle(&mut self, state: LinkState) -> Option { let initial = self.state.is_none(); self.state = Some(state); let transition = match state { LinkState::Connected => Transition::ToConnected { initial }, LinkState::Disconnected => Transition::ToDisconnected { initial }, }; if initial && !self.fire_initial { None } else { Some(transition) } } } #[cfg(test)] mod tests { use super::*; #[test] fn link_query_frame_shape() { let f = link_query_frame(); assert_eq!(f.len(), 61); assert_eq!(&f[..4], &[0x3a, 0x09, 0x22, 0x01]); assert!(f[4..].iter().all(|&b| b == 0)); } #[test] fn link_response_accepted() { let r = [0x3c, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; assert!(is_link_response(&r)); } #[test] fn link_response_rejects_other_report_ids() { let mut r = vec![0x3c, 0x01, 0x00, 0x00, 0x00, 0x01]; r[0] = 0x2c; assert!(!is_link_response(&r)); assert!(!is_link_response(&[0x3d, 0x01])); } #[test] fn link_response_rejects_wrong_echo() { let r = [0x3c, 0x09, 0x00, 0x00, 0x00, 0x01]; assert!(!is_link_response(&r)); } #[test] fn link_response_rejects_short_frames() { assert!(!is_link_response(&[])); assert!(!is_link_response(&[0x3c])); } #[test] fn initial_connect_fires() { let mut t = StateTracker::new(true); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: true }) ); assert_eq!(t.state(), Some(LinkState::Connected)); } #[test] fn initial_disconnect_fires_after_debounce() { let mut t = StateTracker::new(true); assert_eq!(t.update(false), None); assert_eq!(t.state(), None); assert_eq!( t.update(false), Some(Transition::ToDisconnected { initial: true }) ); assert_eq!(t.state(), Some(LinkState::Disconnected)); } #[test] fn single_miss_does_not_disconnect() { let mut t = StateTracker::new(true); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: true }) ); assert_eq!(t.update(false), None); assert_eq!(t.update(true), None); assert_eq!(t.state(), Some(LinkState::Connected)); } #[test] fn misses_reset_on_hit() { let mut t = StateTracker::new(true); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: true }) ); assert_eq!(t.update(false), None); assert_eq!(t.update(true), None); assert_eq!(t.update(false), None); assert_eq!(t.update(true), None); assert_eq!(t.state(), Some(LinkState::Connected)); } #[test] fn two_misses_disconnect_then_reconnect() { let mut t = StateTracker::new(true); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: true }) ); assert_eq!(t.update(false), None); assert_eq!( t.update(false), Some(Transition::ToDisconnected { initial: false }) ); assert_eq!(t.update(false), None); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: false }) ); } #[test] fn no_initial_fires_suppressed_but_later_fire() { let mut t = StateTracker::new(false); assert_eq!(t.update(true), None); assert_eq!(t.state(), Some(LinkState::Connected)); assert_eq!(t.update(false), None); assert_eq!( t.update(false), Some(Transition::ToDisconnected { initial: false }) ); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: false }) ); } #[test] fn suppressed_initial_still_tracks_state() { let mut t = StateTracker::new(false); assert_eq!(t.update(false), None); assert_eq!(t.update(false), None); assert_eq!(t.state(), Some(LinkState::Disconnected)); assert_eq!( t.update(true), Some(Transition::ToConnected { initial: false }) ); } }