diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 237 |
1 files changed, 117 insertions, 120 deletions
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 + )); } } |
