aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
authorjamie <git@skilet.ro>2026-08-29 21:58:43 +0100
committerjamie <git@skilet.ro>2026-08-29 22:06:00 +0100
commitc1110ac81f87ba374696e419f3807f48a3a06d6d (patch)
tree6d03e2747657c1b2e30baba309d4620ce3227eaf /src/main.rs
parent3061396dbb60b5b294c1c0c62c25da8de5c87ffb (diff)
feat(daemon): link-state hooks for headset connect/disconnect
Diffstat (limited to '')
-rw-r--r--src/main.rs194
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 {