//! mmx150d — connection monitoring for the beyerdynamic MMX 150 wireless //! dongle. 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 = 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 } } } fn print_usage() { eprintln!( "usage: mmx150d \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 ' \\\n\ \x20 --on-disconnect 'wpctl set-default '\n\ \n\ find sink ids with: wpctl status" ); } fn cmd_status() -> ExitCode { let Some(dev) = find_mmx150().unwrap_or(None) else { eprintln!("dongle not found"); return ExitCode::from(2); }; let mut hid = match Hidraw::open(&dev.devnode) { Ok(h) => h, Err(e) => { eprintln!("error: {e}"); return ExitCode::from(2); } }; match protocol::link_status(&mut hid) { Ok(LinkState::Connected) => { println!("connected"); ExitCode::SUCCESS } Ok(LinkState::Disconnected) => { println!("disconnected"); ExitCode::from(1) } Err(e) => { eprintln!("error: {e}"); ExitCode::from(2) } } } fn cmd_daemon(args: &[String]) -> ExitCode { let opts = match DaemonArgs::parse(args) { Ok(o) => o, Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } }; let mut hid: Option = None; let mut tracker = StateTracker::new(opts.fire_initial); let mut children: Vec = 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) = &opts.on_connect { spawn_hook(cmd, &mut children); } } Some(Transition::ToDisconnected { initial }) => { log_state("disconnected", initial); if let Some(cmd) = &opts.on_disconnect { spawn_hook(cmd, &mut children); } } None => {} } let elapsed = cycle.elapsed(); if elapsed < opts.interval { std::thread::sleep(opts.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) -> 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) { 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) { 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) } /// Parsed `mmx150 daemon` command line. #[derive(Debug, PartialEq)] struct DaemonArgs { on_connect: Option, on_disconnect: Option, interval: Duration, fire_initial: bool, } impl DaemonArgs { fn parse(args: &[String]) -> Result { 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::().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, }) } } #[cfg(test)] mod tests { use super::*; fn args(list: &[&str]) -> Vec { 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); } #[test] fn daemon_args_full_parse() { let a = DaemonArgs::parse(&args(&[ "--on-connect", "echo up", "--on-disconnect", "echo down", "--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); } #[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()); } #[test] fn daemon_args_rejects_bad_interval() { assert!(DaemonArgs::parse(&args(&["--interval", "0"])).is_err()); assert!(DaemonArgs::parse(&args(&["--interval", "x"])).is_err()); } #[test] fn daemon_args_rejects_unknown_option() { assert!(DaemonArgs::parse(&args(&["--wat"])).is_err()); } }