aboutsummaryrefslogtreecommitdiff
path: root/src/protocol.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/protocol.rs')
-rw-r--r--src/protocol.rs261
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 })
+ );
+ }
+}