//! mmx150d — connection monitoring for the beyerdynamic MMX 150 wireless //! dongle. 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}; const AFTER_HELP: &str = "\ status exit codes: 0 connected, 1 disconnected, 2 dongle missing/error example: mmx150d daemon \\ --on-connect 'wpctl set-default ' \\ --on-disconnect 'wpctl set-default ' 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, } #[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, /// Run CMD (via sh -c) when the headset disconnects #[arg(long)] on_disconnect: Option, /// 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 { 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( 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 = None; let mut tracker = StateTracker::new(!no_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) = 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 let Some(remaining) = interval.checked_sub(elapsed) { std::thread::sleep(remaining); } } } /// 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 std::process::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_or(0, |d| d.as_secs()) } #[cfg(test)] mod tests { use super::*; #[test] fn daemon_args_defaults() { 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 cli = Cli::try_parse_from([ "mmx150d", "daemon", "--on-connect", "echo up", "--on-disconnect", "echo down", "--interval", "5", "--no-initial", ]) .unwrap(); 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_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_unknown_option() { assert!(Cli::try_parse_from(["mmx150d", "daemon", "--wat"]).is_err()); } #[test] fn status_subcommand_parses() { assert!(matches!( Cli::try_parse_from(["mmx150d", "status"]).unwrap().command, Command::Status )); } }