diff options
| author | jamie <git@skilet.ro> | 2026-08-29 21:58:43 +0100 |
|---|---|---|
| committer | jamie <git@skilet.ro> | 2026-08-29 22:06:00 +0100 |
| commit | c1110ac81f87ba374696e419f3807f48a3a06d6d (patch) | |
| tree | 6d03e2747657c1b2e30baba309d4620ce3227eaf /src/main.rs | |
| parent | 3061396dbb60b5b294c1c0c62c25da8de5c87ffb (diff) | |
feat(daemon): link-state hooks for headset connect/disconnect
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 194 |
1 files changed, 177 insertions, 17 deletions
diff --git a/src/main.rs b/src/main.rs index 4648631..1a704fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,43 +1,203 @@ -//! mmx150 — battery and connection monitoring for the beyerdynamic MMX 150 -//! wireless dongle. +//! mmx150 — connection monitoring for the beyerdynamic MMX 150 wireless +//! dongle. -use std::process::ExitCode; +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<String> = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("status") => cmd_status(), - Some("daemon") => cmd_daemon(), + Some("daemon") => cmd_daemon(&args[1..]), Some("probe") => cmd_probe(), _ => { - eprintln!("usage: mmx150 <status|daemon|probe>"); + print_usage(); ExitCode::FAILURE } } } +fn print_usage() { + eprintln!( + "usage: mmx150 <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 mmx150 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" + ); +} + fn cmd_status() -> ExitCode { - match mmx150::hid::find_mmx150() { - Ok(Some(dev)) => { - println!("dongle: {} ({}:{:04x})", dev.name, dev.vid, dev.pid); - println!("connection: unknown (protocol reverse-engineering in progress)"); - println!("battery: unknown"); + let Some(dev) = find_mmx150().unwrap_or(None) else { + eprintln!("dongle not found"); + return ExitCode::from(2); + }; + let hid = match Hidraw::open(&dev.devnode) { + Ok(h) => h, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::from(2); + } + }; + let mut hid = hid; + match protocol::link_status(&mut hid) { + Ok(LinkState::Connected) => { + println!("connected"); ExitCode::SUCCESS } - Ok(None) => { - eprintln!("dongle not found"); - ExitCode::FAILURE + Ok(LinkState::Disconnected) => { + println!("disconnected"); + ExitCode::from(1) } Err(e) => { eprintln!("error: {e}"); - ExitCode::FAILURE + ExitCode::from(2) } } } -fn cmd_daemon() -> ExitCode { - eprintln!("daemon not implemented yet"); - ExitCode::FAILURE +fn cmd_daemon(args: &[String]) -> ExitCode { + let mut on_connect: Option<String> = None; + let mut on_disconnect: Option<String> = None; + let mut interval = Duration::from_secs(2); + let mut no_initial = false; + + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--on-connect" => match it.next() { + Some(c) => on_connect = Some(c.clone()), + None => { + eprintln!("--on-connect needs an argument"); + return ExitCode::FAILURE; + } + }, + "--on-disconnect" => match it.next() { + Some(c) => on_disconnect = Some(c.clone()), + None => { + eprintln!("--on-disconnect needs an argument"); + return ExitCode::FAILURE; + } + }, + "--interval" => match it.next().and_then(|s| s.parse::<u64>().ok()) { + Some(secs) if secs > 0 => interval = Duration::from_secs(secs), + _ => { + eprintln!("--interval needs a number of seconds > 0"); + return ExitCode::FAILURE; + } + }, + "--no-initial" => no_initial = true, + other => { + eprintln!("unknown option: {other}"); + return ExitCode::FAILURE; + } + } + } + + let mut hid: Option<Hidraw> = None; + let mut tracker = StateTracker::new(!no_initial); + let mut children: Vec<Child> = 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 elapsed < interval { + std::thread::sleep(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<Hidraw>) -> 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<Child>) { + 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<Child>) { + 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) } fn cmd_probe() -> ExitCode { |
