diff options
| -rw-r--r-- | src/bin/probe.rs | 103 | ||||
| -rw-r--r-- | src/hid.rs | 46 | ||||
| -rw-r--r-- | src/main.rs | 194 | ||||
| -rw-r--r-- | src/protocol.rs | 261 |
4 files changed, 540 insertions, 64 deletions
diff --git a/src/bin/probe.rs b/src/bin/probe.rs index b2e35d4..e77e6cd 100644 --- a/src/bin/probe.rs +++ b/src/bin/probe.rs @@ -17,8 +17,9 @@ fn main() -> ExitCode { Some("send") => cmd_send(&args[1..]), Some("sweep") => cmd_sweep(&args[1..]), Some("status") => cmd_status_dump(), + Some("selenum") => cmd_sel_enum(&args[1..]), _ => { - eprintln!("usage: mmx150-probe <list|scan|send|sweep|status>"); + eprintln!("usage: mmx150-probe <list|scan|send|sweep|status|selenum>"); ExitCode::FAILURE } } @@ -154,43 +155,9 @@ fn send_and_read(hid: &mut Hidraw, data: &[u8]) -> Option<Vec<u8>> { return None; } } - read_one_response(hid, Duration::from_millis(400)) -} - -/// Wait up to `timeout` for a single input report and return it. -fn read_one_response(hid: &mut Hidraw, timeout: Duration) -> Option<Vec<u8>> { - let deadline = std::time::Instant::now() + timeout; - loop { - let now = std::time::Instant::now(); - if now >= deadline { - return None; - } - let mut set = unsafe { std::mem::zeroed::<libc::fd_set>() }; - unsafe { libc::FD_ZERO(&mut set) }; - unsafe { libc::FD_SET(hid.as_raw_fd(), &mut set) }; - let remaining = deadline.saturating_duration_since(now).as_millis(); - let mut to = libc::timeval { - tv_sec: (remaining / 1000) as i64, - tv_usec: ((remaining % 1000) * 1000) as i64, - }; - let n = unsafe { - libc::select( - hid.as_raw_fd() + 1, - &mut set, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut to, - ) - }; - if n <= 0 { - return None; - } - let mut buf = [0u8; 256]; - match hid.read(&mut buf) { - Ok(len) => return Some(buf[..len].to_vec()), - Err(_) => return None, - } - } + hid.read_with_timeout(Duration::from_millis(400)) + .ok() + .flatten() } /// Sweep a set of candidate commands against the dongle, printing responses @@ -274,8 +241,60 @@ fn cmd_status_dump() -> ExitCode { ExitCode::SUCCESS } +/// Enumerate many sub-command selectors across a report family, dumping any +/// response whose payload carries a real data byte (not just an echo of the +/// selector / a constant). This surfaces battery and state registers. +/// +/// usage: mmx150-probe selenum [report_id] (default: 0x3a) +fn cmd_sel_enum(rest: &[String]) -> ExitCode { + let report_id = rest + .first() + .and_then(|s| u8::from_str_radix(s.trim_start_matches("0x"), 16).ok()) + .unwrap_or(0x3a); + + let Some(dev) = mmx150::hid::find_mmx150().unwrap_or(None) else { + eprintln!("MMX 150 dongle not found"); + return ExitCode::FAILURE; + }; + let hid = match Hidraw::open(&dev.devnode) { + Ok(h) => h, + Err(e) => { + eprintln!("open failed: {e}"); + return ExitCode::FAILURE; + } + }; + let mut hid = hid; + + println!("sweeping report 0x{report_id:02x}, selector range 0x00..0xff"); + for sel in 0x00u8..=0xffu8 { + let mut payload = vec![0u8; 60]; + payload[0] = 0x09; + payload[1] = 0x22; + payload[2] = sel; + let mut frame = vec![report_id]; + frame.extend(payload); + let resp = send_and_read(&mut hid, &frame); + + // Only report responses containing a "real" data byte beyond the + // id/echo/constant region that could be battery/state. + if let Some(b) = resp { + if b.len() < 6 { + continue; + } + // b[1] is the echo of sel, b[5] the constant ack. Look for any + // non-zero byte among the remaining payload that isn't the ack. + let interesting = b[6..].iter().any(|&x| x != 0x00); + if interesting { + println!("sel={sel:02x} resp={}", hex(&b)); + } + std::thread::sleep(Duration::from_millis(8)); + } + } + ExitCode::SUCCESS +} + fn cmd_sweep(rest: &[String]) -> ExitCode { - if rest.len() < 1 { + if rest.is_empty() { eprintln!("usage: mmx150-probe sweep <report_id> <payload_len>"); return ExitCode::FAILURE; } @@ -301,7 +320,7 @@ fn cmd_sweep(rest: &[String]) -> ExitCode { // Baseline: all-zero command. let base = { let mut cmd = vec![report_id]; - cmd.extend(std::iter::repeat(0u8).take(plen)); + cmd.extend(vec![0u8; plen]); send_and_read(&mut hid, &cmd) }; println!( @@ -316,7 +335,7 @@ fn cmd_sweep(rest: &[String]) -> ExitCode { for idx in 1..plen.clamp(1, 12) { for val in [0x01u8, 0x22, 0x2c, 0x2f, 0x80, 0x5a, 0xff] { let mut cmd = vec![report_id]; - cmd.extend(std::iter::repeat(0u8).take(plen)); + cmd.extend(vec![0u8; plen]); cmd[idx] = val; match send_and_read(&mut hid, &cmd) { Some(resp) if Some(&resp) != base.as_ref() => { @@ -339,7 +358,7 @@ fn hex(bytes: &[u8]) -> String { fn parse_hex(s: &str) -> Vec<u8> { let s = s.trim_start_matches("0x"); - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return Vec::new(); } s.as_bytes() @@ -10,6 +10,7 @@ use std::fs; use std::io; use std::os::fd::AsRawFd; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; /// beyerdynamic USB vendor ID. pub const BEYERDYNAMIC_VID: u16 = 0x2e50; @@ -198,6 +199,51 @@ impl Hidraw { self.file.read(buf) } + /// Wait up to `timeout` for one input report on the interrupt IN + /// endpoint. Returns `Ok(None)` if none arrived in time. + pub fn read_with_timeout(&mut self, timeout: Duration) -> io::Result<Option<Vec<u8>>> { + let deadline = Instant::now() + timeout; + loop { + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let mut set = unsafe { std::mem::zeroed::<libc::fd_set>() }; + unsafe { libc::FD_ZERO(&mut set) }; + unsafe { libc::FD_SET(self.file.as_raw_fd(), &mut set) }; + let remaining = deadline.saturating_duration_since(now); + let mut tv = libc::timeval { + tv_sec: remaining.as_secs() as libc::time_t, + tv_usec: remaining.subsec_micros() as libc::suseconds_t, + }; + let n = unsafe { + libc::select( + self.file.as_raw_fd() + 1, + &mut set, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut tv, + ) + }; + if n < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(err); + } + if n == 0 { + return Ok(None); + } + let mut buf = [0u8; 256]; + match self.read(&mut buf) { + Ok(len) => return Ok(Some(buf[..len].to_vec())), + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + } + /// Write to the interrupt OUT endpoint. pub fn write(&mut self, buf: &[u8]) -> io::Result<usize> { use std::io::Write; diff --git a/src/main.rs b/src/main.rs index 4648631..1a704fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,43 +1,203 @@ -//! mmx150 — battery and connection monitoring for the beyerdynamic MMX 150 -//! wireless dongle. +//! mmx150 — connection monitoring for the beyerdynamic MMX 150 wireless +//! dongle. -use std::process::ExitCode; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use mmx150::hid::{find_mmx150, Hidraw}; +use mmx150::protocol::{self, LinkState, StateTracker, Transition}; fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("status") => cmd_status(), - Some("daemon") => cmd_daemon(), + Some("daemon") => cmd_daemon(&args[1..]), Some("probe") => cmd_probe(), _ => { - eprintln!("usage: mmx150 <status|daemon|probe>"); + print_usage(); ExitCode::FAILURE } } } +fn print_usage() { + eprintln!( + "usage: mmx150 <status|daemon>\n\ + \n\ + daemon options:\n\ + \x20 --on-connect CMD run CMD (via sh -c) when the headset connects\n\ + \x20 --on-disconnect CMD run CMD (via sh -c) when the headset disconnects\n\ + \x20 --interval SECS poll interval (default: 2)\n\ + \x20 --no-initial do not fire a hook for the state found at startup\n\ + \n\ + status exit codes: 0 connected, 1 disconnected, 2 dongle missing/error\n\ + \n\ + example:\n\ + \x20 mmx150 daemon \\\n\ + \x20 --on-connect 'wpctl set-default <mmx150-sink-id>' \\\n\ + \x20 --on-disconnect 'wpctl set-default <speakers-sink-id>'\n\ + \n\ + find sink ids with: wpctl status" + ); +} + fn cmd_status() -> ExitCode { - match mmx150::hid::find_mmx150() { - Ok(Some(dev)) => { - println!("dongle: {} ({}:{:04x})", dev.name, dev.vid, dev.pid); - println!("connection: unknown (protocol reverse-engineering in progress)"); - println!("battery: unknown"); + let Some(dev) = find_mmx150().unwrap_or(None) else { + eprintln!("dongle not found"); + return ExitCode::from(2); + }; + let hid = match Hidraw::open(&dev.devnode) { + Ok(h) => h, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::from(2); + } + }; + let mut hid = hid; + match protocol::link_status(&mut hid) { + Ok(LinkState::Connected) => { + println!("connected"); ExitCode::SUCCESS } - Ok(None) => { - eprintln!("dongle not found"); - ExitCode::FAILURE + Ok(LinkState::Disconnected) => { + println!("disconnected"); + ExitCode::from(1) } Err(e) => { eprintln!("error: {e}"); - ExitCode::FAILURE + ExitCode::from(2) } } } -fn cmd_daemon() -> ExitCode { - eprintln!("daemon not implemented yet"); - ExitCode::FAILURE +fn cmd_daemon(args: &[String]) -> ExitCode { + let mut on_connect: Option<String> = None; + let mut on_disconnect: Option<String> = None; + let mut interval = Duration::from_secs(2); + let mut no_initial = false; + + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--on-connect" => match it.next() { + Some(c) => on_connect = Some(c.clone()), + None => { + eprintln!("--on-connect needs an argument"); + return ExitCode::FAILURE; + } + }, + "--on-disconnect" => match it.next() { + Some(c) => on_disconnect = Some(c.clone()), + None => { + eprintln!("--on-disconnect needs an argument"); + return ExitCode::FAILURE; + } + }, + "--interval" => match it.next().and_then(|s| s.parse::<u64>().ok()) { + Some(secs) if secs > 0 => interval = Duration::from_secs(secs), + _ => { + eprintln!("--interval needs a number of seconds > 0"); + return ExitCode::FAILURE; + } + }, + "--no-initial" => no_initial = true, + other => { + eprintln!("unknown option: {other}"); + return ExitCode::FAILURE; + } + } + } + + let mut hid: Option<Hidraw> = None; + let mut tracker = StateTracker::new(!no_initial); + let mut children: Vec<Child> = Vec::new(); + + loop { + let cycle = Instant::now(); + reap_children(&mut children); + + let link_up = poll(&mut hid); + match tracker.update(link_up) { + Some(Transition::ToConnected { initial }) => { + log_state("connected", initial); + if let Some(cmd) = &on_connect { + spawn_hook(cmd, &mut children); + } + } + Some(Transition::ToDisconnected { initial }) => { + log_state("disconnected", initial); + if let Some(cmd) = &on_disconnect { + spawn_hook(cmd, &mut children); + } + } + None => {} + } + + let elapsed = cycle.elapsed(); + if elapsed < interval { + std::thread::sleep(interval - elapsed); + } + } +} + +/// One poll cycle: (re)open the dongle if needed, then query the link. +/// Returns `true` if the headset answered. +fn poll(hid: &mut Option<Hidraw>) -> bool { + if hid.is_none() { + match find_mmx150() { + Ok(Some(dev)) => match Hidraw::open(&dev.devnode) { + Ok(h) => *hid = Some(h), + Err(_) => return false, + }, + _ => return false, + } + } + let Some(h) = hid.as_mut() else { + return false; + }; + match protocol::link_status(h) { + Ok(LinkState::Connected) => true, + Ok(LinkState::Disconnected) => false, + Err(_) => { + *hid = None; + false + } + } +} + +fn spawn_hook(cmd: &str, children: &mut Vec<Child>) { + match Command::new("sh") + .arg("-c") + .arg(cmd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => children.push(child), + Err(e) => eprintln!("[{}] hook spawn failed: {e}", timestamp()), + } +} + +/// Reap finished hook processes so they don't linger as zombies. +fn reap_children(children: &mut Vec<Child>) { + children.retain_mut(|c| c.try_wait().map_or(true, |s| s.is_none())); +} + +fn log_state(state: &str, initial: bool) { + println!( + "[{}] headset {}{}", + timestamp(), + state, + if initial { " (initial)" } else { "" } + ); +} + +fn timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } fn cmd_probe() -> ExitCode { 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 }) + ); + } +} |
