diff options
Diffstat (limited to 'src/protocol.rs')
| -rw-r--r-- | src/protocol.rs | 261 |
1 files changed, 256 insertions, 5 deletions
diff --git a/src/protocol.rs b/src/protocol.rs index 5402e16..3a24195 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,10 +1,12 @@ //! Status protocol for the MMX 150 wireless dongle (reverse-engineered). //! -//! Placeholder: the dongle rejects `GET_FEATURE`/`GET_INPUT` control -//! transfers, so the status query is a write-then-read exchange over the -//! vendor output/feature reports. The exact command bytes and the response -//! layout for battery level and headset link state are being determined in -//! Phase 1 (see `src/bin/probe.rs`). +//! 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 { @@ -21,3 +23,252 @@ pub mod reports { /// 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); + +/// 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 61-byte link query output report (report id `0x3a` + payload). +pub fn link_query_frame() -> Vec<u8> { + let mut frame = vec![0u8; 61]; + frame[0] = reports::OUT_60_C; + frame[1] = 0x09; + frame[2] = 0x22; + frame[3] = 0x01; + frame +} + +/// True if `data` is a link-status response report (`0x3c` echoing the +/// query selector). +pub fn is_link_response(data: &[u8]) -> bool { + data.len() >= 2 && data[0] == 0x3c && data[1] == 0x01 +} + +/// Queries the dongle once for the headset link state. Returns +/// `Ok(Disconnected)` if no response arrives within the timeout window. +pub fn link_status(hid: &mut Hidraw) -> io::Result<LinkState> { + hid.write(&link_query_frame())?; + let deadline = Instant::now() + LINK_RESPONSE_TIMEOUT; + 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(_) => continue, + 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<LinkState>, + misses: u32, + fire_initial: bool, +} + +impl StateTracker { + pub fn new(fire_initial: bool) -> StateTracker { + StateTracker { + state: None, + misses: 0, + fire_initial, + } + } + + /// Current confirmed state, `None` until the first determination. + pub fn state(&self) -> Option<LinkState> { + self.state + } + + /// Feed one poll result (`true` = link response seen) and get any + /// confirmed transition. + pub fn update(&mut self, link_up: bool) -> Option<Transition> { + if link_up { + self.misses = 0; + if self.state == Some(LinkState::Connected) { + return None; + } + let initial = self.state.is_none(); + self.state = Some(LinkState::Connected); + if initial && !self.fire_initial { + None + } else { + Some(Transition::ToConnected { initial }) + } + } else { + self.misses += 1; + if self.misses < LINK_DEBOUNCE_MISSES || self.state == Some(LinkState::Disconnected) { + return None; + } + let initial = self.state.is_none(); + self.state = Some(LinkState::Disconnected); + if initial && !self.fire_initial { + None + } else { + Some(Transition::ToDisconnected { initial }) + } + } + } +} + +#[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 }) + ); + } +} |
