diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/probe.rs | 182 | ||||
| -rw-r--r-- | src/hid.rs | 148 | ||||
| -rw-r--r-- | src/main.rs | 237 | ||||
| -rw-r--r-- | src/protocol.rs | 51 |
4 files changed, 360 insertions, 258 deletions
diff --git a/src/bin/probe.rs b/src/bin/probe.rs index 008565c..cf51d81 100644 --- a/src/bin/probe.rs +++ b/src/bin/probe.rs @@ -11,13 +11,14 @@ use mmx150::hid::{find_devices_by_vendor, Hidraw, BEYERDYNAMIC_VID}; fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); + let rest: &[String] = args.get(1..).unwrap_or(&[]); match args.first().map(String::as_str) { - Some("list") => cmd_list(&args[1..]), + Some("list") => cmd_list(rest), Some("scan") => cmd_scan(), - Some("send") => cmd_send(&args[1..]), - Some("sweep") => cmd_sweep(&args[1..]), + Some("send") => cmd_send(rest), + Some("sweep") => cmd_sweep(rest), Some("status") => cmd_status_dump(), - Some("selenum") => cmd_sel_enum(&args[1..]), + Some("selenum") => cmd_sel_enum(rest), _ => { eprintln!("usage: mmx150-probe <list|scan|send|sweep|status|selenum>"); ExitCode::FAILURE @@ -122,12 +123,15 @@ fn cmd_send(rest: &[String]) -> ExitCode { eprintln!("usage: mmx150-probe send <report_id> <hex bytes...>"); return ExitCode::FAILURE; } - let Ok(report_id) = u8::from_str_radix(rest[0].trim_start_matches("0x"), 16) else { + let Some(id_text) = rest.first() else { + return ExitCode::FAILURE; + }; + let Ok(report_id) = u8::from_str_radix(id_text.trim_start_matches("0x"), 16) else { eprintln!("bad report id"); return ExitCode::FAILURE; }; let mut data = vec![report_id]; - for part in &rest[1..] { + for part in rest.get(1..).unwrap_or(&[]) { let bytes = parse_hex(part); if bytes.is_empty() { eprintln!("bad hex: {part}"); @@ -142,11 +146,11 @@ fn cmd_send(rest: &[String]) -> ExitCode { println!( ">> 0x{report_id:02x} [{}B] {}", - data.len() - 1, - hex(&data[1..]) + data.len().saturating_sub(1), + hex(data.get(1..).unwrap_or(&[])) ); match send_and_read(&mut hid, &data) { - Some(resp) => println!("<< [{}B] {}", resp.len(), hex(&resp)), + Some(response) => println!("<< [{}B] {}", response.len(), hex(&response)), None => println!("<< no response"), } ExitCode::SUCCESS @@ -176,48 +180,20 @@ fn cmd_status_dump() -> ExitCode { // Each row: label, report id, command payload. let cmds: Vec<(&str, u8, Vec<u8>)> = vec![ - ("0x2a echo/sel=0x0b", 0x2a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x22; - c[2] = 0x0b; - c - }), - ("0x2a printf family 09 22", 0x2a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x22; - c[2] = 0x01; - c - }), - ("0x2a eq family 09 00", 0x2a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x00; - c[2] = 0x0b; - c - }), - ("0x3a sel=0x0b", 0x3a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x22; - c[2] = 0x0b; - c - }), - ("0x3a sel=0x05", 0x3a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x22; - c[2] = 0x05; - c - }), - ("0x3a sel=0x01", 0x3a, { - let mut c = vec![0u8; 60]; - c[0] = 0x09; - c[1] = 0x22; - c[2] = 0x01; - c - }), + ("0x2a echo/sel=0x0b", 0x2a, cmd_payload(&[0x09, 0x22, 0x0b])), + ( + "0x2a printf family 09 22", + 0x2a, + cmd_payload(&[0x09, 0x22, 0x01]), + ), + ( + "0x2a eq family 09 00", + 0x2a, + cmd_payload(&[0x09, 0x00, 0x0b]), + ), + ("0x3a sel=0x0b", 0x3a, cmd_payload(&[0x09, 0x22, 0x0b])), + ("0x3a sel=0x05", 0x3a, cmd_payload(&[0x09, 0x22, 0x05])), + ("0x3a sel=0x01", 0x3a, cmd_payload(&[0x09, 0x22, 0x01])), ("0x2d signature", 0x2d, vec![0u8; 16]), ("0x3d signature", 0x3d, vec![0u8; 16]), ]; @@ -225,21 +201,33 @@ fn cmd_status_dump() -> ExitCode { for (label, rid, payload) in cmds { let mut frame = vec![rid]; frame.extend(payload); - let resp = send_and_read(&mut hid, &frame); + let response = send_and_read(&mut hid, &frame); println!( "{label:<26} >>0x{rid:02x} << {}", - resp.map(|b| hex(&b)).unwrap_or_else(|| "<none>".into()) + response + .as_deref() + .map_or_else(|| "<none>".to_string(), hex) ); std::thread::sleep(Duration::from_millis(120)); } ExitCode::SUCCESS } +/// A 60-byte command payload with `bytes` at the front and zero padding. +fn cmd_payload(bytes: &[u8]) -> Vec<u8> { + let mut payload = vec![0u8; 60]; + let n = bytes.len().min(60); + if let (Some(slot), Some(source)) = (payload.get_mut(..n), bytes.get(..n)) { + slot.copy_from_slice(source); + } + payload +} + /// 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) +/// usage: mmx150-probe selenum `[report_id]` (default: 0x3a) fn cmd_sel_enum(rest: &[String]) -> ExitCode { let report_id = rest .first() @@ -252,25 +240,18 @@ fn cmd_sel_enum(rest: &[String]) -> ExitCode { 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); + frame.extend(cmd_payload(&[0x09, 0x22, sel])); + let response = 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)); + if let Some(bytes) = response { + if bytes + .get(6..) + .is_some_and(|tail| tail.iter().any(|&x| x != 0x00)) + { + println!("sel={sel:02x} resp={}", hex(&bytes)); } std::thread::sleep(Duration::from_millis(8)); } @@ -283,7 +264,10 @@ fn cmd_sweep(rest: &[String]) -> ExitCode { eprintln!("usage: mmx150-probe sweep <report_id> <payload_len>"); return ExitCode::FAILURE; } - let Ok(report_id) = u8::from_str_radix(rest[0].trim_start_matches("0x"), 16) else { + let Some(id_text) = rest.first() else { + return ExitCode::FAILURE; + }; + let Ok(report_id) = u8::from_str_radix(id_text.trim_start_matches("0x"), 16) else { eprintln!("bad report id"); return ExitCode::FAILURE; }; @@ -294,16 +278,16 @@ fn cmd_sweep(rest: &[String]) -> ExitCode { }; // Baseline: all-zero command. - let base = { + let baseline = { let mut cmd = vec![report_id]; cmd.extend(vec![0u8; plen]); send_and_read(&mut hid, &cmd) }; println!( "baseline (all zero): {}", - base.as_ref() - .map(|b| hex(b.as_slice())) - .unwrap_or_else(|| "<none>".into()) + baseline + .as_deref() + .map_or_else(|| "<none>".to_string(), hex) ); // Try setting individual low nibbles / bytes across the header to probe @@ -312,10 +296,12 @@ fn cmd_sweep(rest: &[String]) -> ExitCode { for val in [0x01u8, 0x22, 0x2c, 0x2f, 0x80, 0x5a, 0xff] { let mut cmd = vec![report_id]; cmd.extend(vec![0u8; plen]); - cmd[idx] = val; + if let Some(slot) = cmd.get_mut(idx) { + *slot = val; + } match send_and_read(&mut hid, &cmd) { - Some(resp) if Some(&resp) != base.as_ref() => { - println!("cmd[{idx:02}]=0x{val:02x} -> {}", hex(&resp)); + Some(response) if Some(&response) != baseline.as_ref() => { + println!("cmd[{idx:02}]=0x{val:02x} -> {}", hex(&response)); } _ => {} } @@ -338,12 +324,44 @@ fn parse_hex(s: &str) -> Vec<u8> { return Vec::new(); } s.as_bytes() - .chunks(2) - .map(|c| { - let hi = (c[0] as char).to_digit(16)?; - let lo = (c[1] as char).to_digit(16)?; - Some((hi * 16 + lo) as u8) + .chunks_exact(2) + .map(|pair| { + let [hi, lo] = pair else { return None }; + let hi = char::from(*hi).to_digit(16)?; + let lo = char::from(*lo).to_digit(16)?; + u8::try_from((hi << 4) | lo).ok() }) .collect::<Option<Vec<u8>>>() .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_pairs() { + assert_eq!(parse_hex("0922"), vec![0x09, 0x22]); + assert_eq!(parse_hex("0x0922"), vec![0x09, 0x22]); + assert_eq!(parse_hex("ff"), vec![0xff]); + assert_eq!(parse_hex("FF"), vec![0xff]); + assert_eq!(parse_hex("0A0b"), vec![0x0a, 0x0b]); + } + + #[test] + fn rejects_odd_length() { + assert!(parse_hex("092").is_empty()); + } + + #[test] + fn rejects_invalid_digits() { + assert!(parse_hex("zz").is_empty()); + assert!(parse_hex("0g").is_empty()); + } + + #[test] + fn empty_input_yields_empty() { + assert!(parse_hex("").is_empty()); + assert!(parse_hex("0x").is_empty()); + } +} @@ -6,6 +6,7 @@ //! `HIDIOCSFEATURE`, `HIDIOCGINPUT`, `HIDIOCGSOUTPUT` ioctls plus plain //! `read`/`write` on the interrupt endpoints. +use std::cmp::Ordering; use std::fs; use std::io; use std::os::fd::AsRawFd; @@ -27,10 +28,12 @@ const IOC_READ: u32 = 2; const IOC_WRITE: u32 = 1; fn ioc(dir: u32, ty: u8, nr: u32, size: u32) -> libc::c_ulong { - ((dir << IOC_DIRSHIFT) - | ((ty as u32) << IOC_TYPESHIFT) - | (nr << IOC_NRSHIFT) - | (size << IOC_SIZESHIFT)) as libc::c_ulong + u64::from( + (dir << IOC_DIRSHIFT) + | (u32::from(ty) << IOC_TYPESHIFT) + | (nr << IOC_NRSHIFT) + | (size << IOC_SIZESHIFT), + ) } const HIDIOCGFEATURE_NR: u32 = 0x06; @@ -62,11 +65,19 @@ pub struct HidDevice { /// Enumerate hidraw devices matching `vid`/`pid` by reading the sysfs `uevent` /// of each `/sys/class/hidraw/hidrawN`. +/// +/// # Errors +/// +/// Returns an error if `/sys/class/hidraw` cannot be listed. pub fn find_devices(vid: u16, pid: u16) -> io::Result<Vec<HidDevice>> { find_with(|v, p| v == vid && p == pid) } /// Find the first (only) MMX 150 dongle HID device. +/// +/// # Errors +/// +/// Propagates errors from [`find_devices`]. pub fn find_mmx150() -> io::Result<Option<HidDevice>> { Ok(find_devices(BEYERDYNAMIC_VID, MMX150_DONGLE_PID)? .into_iter() @@ -74,6 +85,10 @@ pub fn find_mmx150() -> io::Result<Option<HidDevice>> { } /// Enumerate all hidraw devices belonging to a vendor. +/// +/// # Errors +/// +/// Returns an error if `/sys/class/hidraw` cannot be listed. pub fn find_devices_by_vendor(vid: u16) -> io::Result<Vec<HidDevice>> { find_with(|v, _| v == vid) } @@ -122,12 +137,17 @@ pub struct Hidraw { } impl Hidraw { - pub fn open(devnode: impl AsRef<Path>) -> io::Result<Hidraw> { + /// Open a hidraw device node for reading and writing. + /// + /// # Errors + /// + /// Returns an error if the node cannot be opened for read/write access. + pub fn open(devnode: impl AsRef<Path>) -> io::Result<Self> { let file = fs::OpenOptions::new() .read(true) .write(true) .open(devnode.as_ref())?; - Ok(Hidraw { + Ok(Self { file, devnode: devnode.as_ref().to_path_buf(), }) @@ -135,33 +155,63 @@ impl Hidraw { /// `HIDIOCGFEATURE`: fetch `report_id` into `data` (report length + 1 /// bytes including the report-id byte). Returns bytes received. + /// + /// # Errors + /// + /// Returns an error if the ioctl fails. pub fn get_feature(&self, report_id: u8, len: usize) -> io::Result<Vec<u8>> { let mut buf = vec![0u8; len]; - buf[0] = report_id; - self.raw_ioctl(hid_get_feature_cmd(len as u32), &mut buf)?; + if let Some(first) = buf.first_mut() { + *first = report_id; + } + self.raw_ioctl(hid_get_feature_cmd(report_size(len)), &mut buf)?; Ok(buf) } /// `HIDIOCSFEATURE`: set a feature report (`data[0]` is the report id). + /// + /// # Errors + /// + /// Returns an error if the ioctl fails. pub fn set_feature(&self, data: &[u8]) -> io::Result<usize> { - self.raw_ioctl(hid_set_feature_cmd(data.len() as u32), &mut data.to_vec()) + self.raw_ioctl( + hid_set_feature_cmd(report_size(data.len())), + &mut data.to_vec(), + ) } /// `HIDIOCGINPUT`: fetch an input report by id. + /// + /// # Errors + /// + /// Returns an error if the ioctl fails. pub fn get_input(&self, report_id: u8, len: usize) -> io::Result<Vec<u8>> { let mut buf = vec![0u8; len]; - buf[0] = report_id; - self.raw_ioctl(hid_get_input_cmd(len as u32), &mut buf)?; + if let Some(first) = buf.first_mut() { + *first = report_id; + } + self.raw_ioctl(hid_get_input_cmd(report_size(len)), &mut buf)?; Ok(buf) } /// `HIDIOCGSOUTPUT`: send an output report (`data[0]` is the report id) /// via a control transfer, without going through the interrupt endpoint. + /// + /// # Errors + /// + /// Returns an error if the ioctl fails. pub fn send_output(&self, data: &[u8]) -> io::Result<usize> { - self.raw_ioctl(hid_get_output_cmd(data.len() as u32), &mut data.to_vec()) + self.raw_ioctl( + hid_get_output_cmd(report_size(data.len())), + &mut data.to_vec(), + ) } /// Read from the interrupt IN endpoint (asynchronous input reports). + /// + /// # Errors + /// + /// Returns an error if the read fails. pub fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { use std::io::Read; self.file.read(buf) @@ -169,50 +219,63 @@ impl Hidraw { /// Wait up to `timeout` for one input report on the interrupt IN /// endpoint. Returns `Ok(None)` if none arrived in time. + /// + /// # Errors + /// + /// Returns an error if `select` or `read` fails with anything other than + /// `EINTR`. pub fn read_with_timeout(&mut self, timeout: Duration) -> io::Result<Option<Vec<u8>>> { - let deadline = Instant::now() + timeout; + let deadline = Instant::now() + .checked_add(timeout) + .unwrap_or_else(Instant::now); loop { - let now = Instant::now(); - if now >= deadline { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return Ok(None); - } + }; + let fd = self.file.as_raw_fd(); 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); + unsafe { libc::FD_ZERO(std::ptr::addr_of_mut!(set)) }; + unsafe { libc::FD_SET(fd, std::ptr::addr_of_mut!(set)) }; let mut tv = libc::timeval { - tv_sec: remaining.as_secs() as libc::time_t, - tv_usec: remaining.subsec_micros() as libc::suseconds_t, + tv_sec: libc::time_t::try_from(remaining.as_secs()).unwrap_or(libc::time_t::MAX), + tv_usec: libc::suseconds_t::from(remaining.subsec_micros()), }; let n = unsafe { libc::select( - self.file.as_raw_fd() + 1, - &mut set, + fd.saturating_add(1), + std::ptr::addr_of_mut!(set), std::ptr::null_mut(), std::ptr::null_mut(), - &mut tv, + std::ptr::addr_of_mut!(tv), ) }; - if n < 0 { - let err = io::Error::last_os_error(); - if err.kind() == io::ErrorKind::Interrupted { - continue; + match n.cmp(&0) { + Ordering::Less => { + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + return Err(err); + } + } + Ordering::Equal => return Ok(None), + Ordering::Greater => { + let mut buf = [0u8; 256]; + match self.read(&mut buf) { + Ok(len) => { + return Ok(Some(buf.get(..len).unwrap_or(&[]).to_vec())); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } } - 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. + /// + /// # Errors + /// + /// Returns an error if the write fails. pub fn write(&mut self, buf: &[u8]) -> io::Result<usize> { use std::io::Write; self.file.write(buf) @@ -224,13 +287,13 @@ impl Hidraw { libc::ioctl( self.file.as_raw_fd(), request, - buf.as_mut_ptr() as *mut libc::c_void, + buf.as_mut_ptr().cast::<libc::c_void>(), ) }; if ret < 0 { return Err(io::Error::last_os_error()); } - Ok(ret as usize) + Ok(usize::try_from(ret).unwrap_or(0)) } } @@ -240,6 +303,11 @@ impl std::os::fd::AsRawFd for Hidraw { } } +/// Clamp a report length to what the ioctl size field can hold. +fn report_size(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX) +} + fn read_sysfs(path: &Path) -> Option<String> { fs::read_to_string(path).ok() } diff --git a/src/main.rs b/src/main.rs index 9392692..a21959b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,43 +1,73 @@ //! mmx150d — connection monitoring for the beyerdynamic MMX 150 wireless //! dongle. -use std::process::{Child, Command, ExitCode, Stdio}; +use std::process::{Child, ExitCode, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use clap::{Parser, Subcommand}; + 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(&args[1..]), - _ => { - print_usage(); - ExitCode::FAILURE - } - } +const AFTER_HELP: &str = "\ +status exit codes: 0 connected, 1 disconnected, 2 dongle missing/error + +example: + mmx150d daemon \\ + --on-connect 'wpctl set-default <mmx150-sink-id>' \\ + --on-disconnect 'wpctl set-default <speakers-sink-id>' + +find sink ids with: wpctl status"; + +#[derive(Parser, Debug)] +#[command( + name = "mmx150d", + version, + about = "Connection monitoring for the beyerdynamic MMX 150 wireless dongle", + after_help = AFTER_HELP +)] +struct Cli { + #[command(subcommand)] + command: Command, } -fn print_usage() { - eprintln!( - "usage: mmx150d <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 mmx150d 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" - ); +#[derive(Subcommand, Debug)] +enum Command { + /// Print the current headset link state + Status, + /// Run hooks when the headset connects or disconnects + Daemon { + /// Run CMD (via sh -c) when the headset connects + #[arg(long)] + on_connect: Option<String>, + /// Run CMD (via sh -c) when the headset disconnects + #[arg(long)] + on_disconnect: Option<String>, + /// Poll interval in seconds + #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u64).range(1..))] + interval: u64, + /// Do not fire a hook for the state found at startup + #[arg(long)] + no_initial: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.command { + Command::Status => cmd_status(), + Command::Daemon { + on_connect, + on_disconnect, + interval, + no_initial, + } => cmd_daemon( + on_connect.as_deref(), + on_disconnect.as_deref(), + interval, + no_initial, + ), + } } fn cmd_status() -> ExitCode { @@ -67,17 +97,17 @@ fn cmd_status() -> ExitCode { } } } -fn cmd_daemon(args: &[String]) -> ExitCode { - let opts = match DaemonArgs::parse(args) { - Ok(o) => o, - Err(e) => { - eprintln!("{e}"); - return ExitCode::FAILURE; - } - }; + +fn cmd_daemon( + on_connect: Option<&str>, + on_disconnect: Option<&str>, + interval_secs: u64, + no_initial: bool, +) -> ExitCode { + let interval = Duration::from_secs(interval_secs); let mut hid: Option<Hidraw> = None; - let mut tracker = StateTracker::new(opts.fire_initial); + let mut tracker = StateTracker::new(!no_initial); let mut children: Vec<Child> = Vec::new(); loop { @@ -88,13 +118,13 @@ fn cmd_daemon(args: &[String]) -> ExitCode { match tracker.update(link_up) { Some(Transition::ToConnected { initial }) => { log_state("connected", initial); - if let Some(cmd) = &opts.on_connect { + if let Some(cmd) = on_connect { spawn_hook(cmd, &mut children); } } Some(Transition::ToDisconnected { initial }) => { log_state("disconnected", initial); - if let Some(cmd) = &opts.on_disconnect { + if let Some(cmd) = on_disconnect { spawn_hook(cmd, &mut children); } } @@ -102,8 +132,8 @@ fn cmd_daemon(args: &[String]) -> ExitCode { } let elapsed = cycle.elapsed(); - if elapsed < opts.interval { - std::thread::sleep(opts.interval - elapsed); + if let Some(remaining) = interval.checked_sub(elapsed) { + std::thread::sleep(remaining); } } } @@ -134,7 +164,7 @@ fn poll(hid: &mut Option<Hidraw>) -> bool { } fn spawn_hook(cmd: &str, children: &mut Vec<Child>) { - match Command::new("sh") + match std::process::Command::new("sh") .arg("-c") .arg(cmd) .stdin(Stdio::null()) @@ -164,80 +194,36 @@ fn log_state(state: &str, initial: bool) { fn timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Parsed `mmx150 daemon` command line. -#[derive(Debug, PartialEq)] -struct DaemonArgs { - on_connect: Option<String>, - on_disconnect: Option<String>, - interval: Duration, - fire_initial: bool, -} - -impl DaemonArgs { - fn parse(args: &[String]) -> Result<DaemonArgs, String> { - let mut on_connect = None; - let mut on_disconnect = None; - let mut interval = Duration::from_secs(2); - let mut fire_initial = true; - - let mut it = args.iter(); - while let Some(arg) = it.next() { - match arg.as_str() { - "--on-connect" => { - on_connect = Some(it.next().ok_or("--on-connect needs an argument")?.clone()) - } - "--on-disconnect" => { - on_disconnect = Some( - it.next() - .ok_or("--on-disconnect needs an argument")? - .clone(), - ) - } - "--interval" => { - let secs = it - .next() - .and_then(|s| s.parse::<u64>().ok()) - .filter(|&s| s > 0) - .ok_or("--interval needs a number of seconds > 0")?; - interval = Duration::from_secs(secs); - } - "--no-initial" => fire_initial = false, - other => return Err(format!("unknown option: {other}")), - } - } - Ok(DaemonArgs { - on_connect, - on_disconnect, - interval, - fire_initial, - }) - } + .map_or(0, |d| d.as_secs()) } #[cfg(test)] mod tests { use super::*; - fn args(list: &[&str]) -> Vec<String> { - list.iter().map(|s| s.to_string()).collect() - } - #[test] fn daemon_args_defaults() { - let a = DaemonArgs::parse(&args(&[])).unwrap(); - assert_eq!(a.interval, Duration::from_secs(2)); - assert!(a.fire_initial); - assert_eq!(a.on_connect, None); - assert_eq!(a.on_disconnect, None); + let cli = Cli::try_parse_from(["mmx150d", "daemon"]).unwrap(); + let Command::Daemon { + on_connect, + on_disconnect, + interval, + no_initial, + } = cli.command + else { + panic!("expected daemon subcommand"); + }; + assert_eq!(interval, 2); + assert!(!no_initial); + assert_eq!(on_connect, None); + assert_eq!(on_disconnect, None); } #[test] fn daemon_args_full_parse() { - let a = DaemonArgs::parse(&args(&[ + let cli = Cli::try_parse_from([ + "mmx150d", + "daemon", "--on-connect", "echo up", "--on-disconnect", @@ -245,29 +231,40 @@ mod tests { "--interval", "5", "--no-initial", - ])) + ]) .unwrap(); - assert_eq!(a.on_connect.as_deref(), Some("echo up")); - assert_eq!(a.on_disconnect.as_deref(), Some("echo down")); - assert_eq!(a.interval, Duration::from_secs(5)); - assert!(!a.fire_initial); + let Command::Daemon { + on_connect, + on_disconnect, + interval, + no_initial, + } = cli.command + else { + panic!("expected daemon subcommand"); + }; + assert_eq!(on_connect.as_deref(), Some("echo up")); + assert_eq!(on_disconnect.as_deref(), Some("echo down")); + assert_eq!(interval, 5); + assert!(no_initial); } #[test] - fn daemon_args_rejects_missing_option_argument() { - assert!(DaemonArgs::parse(&args(&["--on-connect"])).is_err()); - assert!(DaemonArgs::parse(&args(&["--on-disconnect"])).is_err()); - assert!(DaemonArgs::parse(&args(&["--interval"])).is_err()); + fn daemon_args_rejects_bad_interval() { + assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "0"]).is_err()); + assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "x"]).is_err()); + assert!(Cli::try_parse_from(["mmx150d", "daemon", "--interval", "1"]).is_ok()); } #[test] - fn daemon_args_rejects_bad_interval() { - assert!(DaemonArgs::parse(&args(&["--interval", "0"])).is_err()); - assert!(DaemonArgs::parse(&args(&["--interval", "x"])).is_err()); + fn daemon_args_rejects_unknown_option() { + assert!(Cli::try_parse_from(["mmx150d", "daemon", "--wat"]).is_err()); } #[test] - fn daemon_args_rejects_unknown_option() { - assert!(DaemonArgs::parse(&args(&["--wat"])).is_err()); + fn status_subcommand_parses() { + assert!(matches!( + Cli::try_parse_from(["mmx150d", "status"]).unwrap().command, + Command::Status + )); } } diff --git a/src/protocol.rs b/src/protocol.rs index d6fce5f..39afac0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -62,7 +62,8 @@ pub enum Transition { } /// Builds the link query output report (report id `0x3a` + payload). -pub fn link_query_frame() -> [u8; LINK_QUERY_LEN] { +#[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]; @@ -73,19 +74,29 @@ pub fn link_query_frame() -> [u8; LINK_QUERY_LEN] { /// 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] == LINK_RESPONSE_REPORT && data[1] == LINK_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<LinkState> { hid.write(&link_query_frame())?; - let deadline = Instant::now() + LINK_RESPONSE_TIMEOUT; + 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(_) => continue, + Some(_) => {} None => break, } } @@ -93,9 +104,11 @@ pub fn link_status(hid: &mut Hidraw) -> io::Result<LinkState> { } /// 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. +/// 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>, @@ -104,8 +117,10 @@ pub struct StateTracker { } impl StateTracker { - pub fn new(fire_initial: bool) -> StateTracker { - 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, @@ -113,22 +128,26 @@ impl StateTracker { } /// Current confirmed state, `None` until the first determination. - pub fn state(&self) -> Option<LinkState> { + #[must_use] + pub const 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> { + #[must_use] + pub const fn update(&mut self, link_up: bool) -> Option<Transition> { if link_up { self.misses = 0; - if self.state == Some(LinkState::Connected) { + if matches!(self.state, Some(LinkState::Connected)) { return None; } self.settle(LinkState::Connected) } else { - self.misses += 1; - if self.misses < LINK_DEBOUNCE_MISSES || self.state == Some(LinkState::Disconnected) { + 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) @@ -137,7 +156,7 @@ impl StateTracker { /// Pin the state, producing the transition unless it is the first /// determination and initial hooks are suppressed. - fn settle(&mut self, state: LinkState) -> Option<Transition> { + const fn settle(&mut self, state: LinkState) -> Option<Transition> { let initial = self.state.is_none(); self.state = Some(state); let transition = match state { |
