aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorjamie <git@skilet.ro>2026-08-29 22:51:38 +0100
committerjamie <git@skilet.ro>2026-08-29 22:55:36 +0100
commit6ade07db214e8571a439b474e6c4e6a4c861d098 (patch)
tree0c4864b5d4a2abc0b05cfdb83596d87edb018dac /src
parent4c42e8c9d7ec1b2e94267038d5ee38a714ca2955 (diff)
feat(cli): adopt clap and strict clippy configHEADmain
Diffstat (limited to '')
-rw-r--r--src/bin/probe.rs182
-rw-r--r--src/hid.rs148
-rw-r--r--src/main.rs237
-rw-r--r--src/protocol.rs51
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());
+ }
+}
diff --git a/src/hid.rs b/src/hid.rs
index 72f67fa..f8f9447 100644
--- a/src/hid.rs
+++ b/src/hid.rs
@@ -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 {