aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/bin/probe.rs103
-rw-r--r--src/hid.rs46
-rw-r--r--src/main.rs194
-rw-r--r--src/protocol.rs261
4 files changed, 540 insertions, 64 deletions
diff --git a/src/bin/probe.rs b/src/bin/probe.rs
index b2e35d4..e77e6cd 100644
--- a/src/bin/probe.rs
+++ b/src/bin/probe.rs
@@ -17,8 +17,9 @@ fn main() -> ExitCode {
Some("send") => cmd_send(&args[1..]),
Some("sweep") => cmd_sweep(&args[1..]),
Some("status") => cmd_status_dump(),
+ Some("selenum") => cmd_sel_enum(&args[1..]),
_ => {
- eprintln!("usage: mmx150-probe <list|scan|send|sweep|status>");
+ eprintln!("usage: mmx150-probe <list|scan|send|sweep|status|selenum>");
ExitCode::FAILURE
}
}
@@ -154,43 +155,9 @@ fn send_and_read(hid: &mut Hidraw, data: &[u8]) -> Option<Vec<u8>> {
return None;
}
}
- read_one_response(hid, Duration::from_millis(400))
-}
-
-/// Wait up to `timeout` for a single input report and return it.
-fn read_one_response(hid: &mut Hidraw, timeout: Duration) -> Option<Vec<u8>> {
- let deadline = std::time::Instant::now() + timeout;
- loop {
- let now = std::time::Instant::now();
- if now >= deadline {
- return None;
- }
- let mut set = unsafe { std::mem::zeroed::<libc::fd_set>() };
- unsafe { libc::FD_ZERO(&mut set) };
- unsafe { libc::FD_SET(hid.as_raw_fd(), &mut set) };
- let remaining = deadline.saturating_duration_since(now).as_millis();
- let mut to = libc::timeval {
- tv_sec: (remaining / 1000) as i64,
- tv_usec: ((remaining % 1000) * 1000) as i64,
- };
- let n = unsafe {
- libc::select(
- hid.as_raw_fd() + 1,
- &mut set,
- std::ptr::null_mut(),
- std::ptr::null_mut(),
- &mut to,
- )
- };
- if n <= 0 {
- return None;
- }
- let mut buf = [0u8; 256];
- match hid.read(&mut buf) {
- Ok(len) => return Some(buf[..len].to_vec()),
- Err(_) => return None,
- }
- }
+ hid.read_with_timeout(Duration::from_millis(400))
+ .ok()
+ .flatten()
}
/// Sweep a set of candidate commands against the dongle, printing responses
@@ -274,8 +241,60 @@ fn cmd_status_dump() -> ExitCode {
ExitCode::SUCCESS
}
+/// Enumerate many sub-command selectors across a report family, dumping any
+/// response whose payload carries a real data byte (not just an echo of the
+/// selector / a constant). This surfaces battery and state registers.
+///
+/// usage: mmx150-probe selenum [report_id] (default: 0x3a)
+fn cmd_sel_enum(rest: &[String]) -> ExitCode {
+ let report_id = rest
+ .first()
+ .and_then(|s| u8::from_str_radix(s.trim_start_matches("0x"), 16).ok())
+ .unwrap_or(0x3a);
+
+ let Some(dev) = mmx150::hid::find_mmx150().unwrap_or(None) else {
+ eprintln!("MMX 150 dongle not found");
+ return ExitCode::FAILURE;
+ };
+ let hid = match Hidraw::open(&dev.devnode) {
+ Ok(h) => h,
+ Err(e) => {
+ eprintln!("open failed: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+ let mut hid = hid;
+
+ println!("sweeping report 0x{report_id:02x}, selector range 0x00..0xff");
+ for sel in 0x00u8..=0xffu8 {
+ let mut payload = vec![0u8; 60];
+ payload[0] = 0x09;
+ payload[1] = 0x22;
+ payload[2] = sel;
+ let mut frame = vec![report_id];
+ frame.extend(payload);
+ let resp = send_and_read(&mut hid, &frame);
+
+ // Only report responses containing a "real" data byte beyond the
+ // id/echo/constant region that could be battery/state.
+ if let Some(b) = resp {
+ if b.len() < 6 {
+ continue;
+ }
+ // b[1] is the echo of sel, b[5] the constant ack. Look for any
+ // non-zero byte among the remaining payload that isn't the ack.
+ let interesting = b[6..].iter().any(|&x| x != 0x00);
+ if interesting {
+ println!("sel={sel:02x} resp={}", hex(&b));
+ }
+ std::thread::sleep(Duration::from_millis(8));
+ }
+ }
+ ExitCode::SUCCESS
+}
+
fn cmd_sweep(rest: &[String]) -> ExitCode {
- if rest.len() < 1 {
+ if rest.is_empty() {
eprintln!("usage: mmx150-probe sweep <report_id> <payload_len>");
return ExitCode::FAILURE;
}
@@ -301,7 +320,7 @@ fn cmd_sweep(rest: &[String]) -> ExitCode {
// Baseline: all-zero command.
let base = {
let mut cmd = vec![report_id];
- cmd.extend(std::iter::repeat(0u8).take(plen));
+ cmd.extend(vec![0u8; plen]);
send_and_read(&mut hid, &cmd)
};
println!(
@@ -316,7 +335,7 @@ fn cmd_sweep(rest: &[String]) -> ExitCode {
for idx in 1..plen.clamp(1, 12) {
for val in [0x01u8, 0x22, 0x2c, 0x2f, 0x80, 0x5a, 0xff] {
let mut cmd = vec![report_id];
- cmd.extend(std::iter::repeat(0u8).take(plen));
+ cmd.extend(vec![0u8; plen]);
cmd[idx] = val;
match send_and_read(&mut hid, &cmd) {
Some(resp) if Some(&resp) != base.as_ref() => {
@@ -339,7 +358,7 @@ fn hex(bytes: &[u8]) -> String {
fn parse_hex(s: &str) -> Vec<u8> {
let s = s.trim_start_matches("0x");
- if s.len() % 2 != 0 {
+ if !s.len().is_multiple_of(2) {
return Vec::new();
}
s.as_bytes()
diff --git a/src/hid.rs b/src/hid.rs
index da70d81..6b09e98 100644
--- a/src/hid.rs
+++ b/src/hid.rs
@@ -10,6 +10,7 @@ use std::fs;
use std::io;
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
/// beyerdynamic USB vendor ID.
pub const BEYERDYNAMIC_VID: u16 = 0x2e50;
@@ -198,6 +199,51 @@ impl Hidraw {
self.file.read(buf)
}
+ /// Wait up to `timeout` for one input report on the interrupt IN
+ /// endpoint. Returns `Ok(None)` if none arrived in time.
+ pub fn read_with_timeout(&mut self, timeout: Duration) -> io::Result<Option<Vec<u8>>> {
+ let deadline = Instant::now() + timeout;
+ loop {
+ let now = Instant::now();
+ if now >= deadline {
+ return Ok(None);
+ }
+ let mut set = unsafe { std::mem::zeroed::<libc::fd_set>() };
+ unsafe { libc::FD_ZERO(&mut set) };
+ unsafe { libc::FD_SET(self.file.as_raw_fd(), &mut set) };
+ let remaining = deadline.saturating_duration_since(now);
+ let mut tv = libc::timeval {
+ tv_sec: remaining.as_secs() as libc::time_t,
+ tv_usec: remaining.subsec_micros() as libc::suseconds_t,
+ };
+ let n = unsafe {
+ libc::select(
+ self.file.as_raw_fd() + 1,
+ &mut set,
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ &mut tv,
+ )
+ };
+ if n < 0 {
+ let err = io::Error::last_os_error();
+ if err.kind() == io::ErrorKind::Interrupted {
+ continue;
+ }
+ return Err(err);
+ }
+ if n == 0 {
+ return Ok(None);
+ }
+ let mut buf = [0u8; 256];
+ match self.read(&mut buf) {
+ Ok(len) => return Ok(Some(buf[..len].to_vec())),
+ Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
+ Err(e) => return Err(e),
+ }
+ }
+ }
+
/// Write to the interrupt OUT endpoint.
pub fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
use std::io::Write;
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 {
diff --git a/src/protocol.rs b/src/protocol.rs
index 5402e16..3a24195 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -1,10 +1,12 @@
//! Status protocol for the MMX 150 wireless dongle (reverse-engineered).
//!
-//! Placeholder: the dongle rejects `GET_FEATURE`/`GET_INPUT` control
-//! transfers, so the status query is a write-then-read exchange over the
-//! vendor output/feature reports. The exact command bytes and the response
-//! layout for battery level and headset link state are being determined in
-//! Phase 1 (see `src/bin/probe.rs`).
+//! The dongle rejects `GET_FEATURE`/`GET_INPUT` control transfers, so status
+//! queries are write-then-read exchanges over the vendor output reports.
+//!
+//! Link state: writing output report `0x3a` (`3a 09 22 01 00...`) elicits an
+//! input report `0x3c` (`3c 01 00 00 00 01 ...`) on the interrupt IN endpoint
+//! if and only if the headset is linked to the dongle. No response within the
+//! timeout means the headset is powered off or out of range.
/// Vendor output report IDs available on the dongle.
pub mod reports {
@@ -21,3 +23,252 @@ pub mod reports {
/// 16-byte output report.
pub const OUT_16_B: u8 = 0x3d;
}
+
+use std::io;
+use std::time::{Duration, Instant};
+
+use crate::hid::Hidraw;
+
+/// Consecutive missed polls required before declaring the headset
+/// disconnected (guards against dropped RF packets).
+pub const LINK_DEBOUNCE_MISSES: u32 = 2;
+
+/// How long to wait for a link response after sending the query.
+const LINK_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
+
+/// Headset link state as seen through the dongle.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LinkState {
+ Connected,
+ Disconnected,
+}
+
+/// A confirmed change of [`LinkState`], with `initial` set when the
+/// transition is the daemon's first determination of the state (rather than
+/// a change from a previously confirmed state).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Transition {
+ ToConnected { initial: bool },
+ ToDisconnected { initial: bool },
+}
+
+/// Builds the 61-byte link query output report (report id `0x3a` + payload).
+pub fn link_query_frame() -> Vec<u8> {
+ let mut frame = vec![0u8; 61];
+ frame[0] = reports::OUT_60_C;
+ frame[1] = 0x09;
+ frame[2] = 0x22;
+ frame[3] = 0x01;
+ frame
+}
+
+/// True if `data` is a link-status response report (`0x3c` echoing the
+/// query selector).
+pub fn is_link_response(data: &[u8]) -> bool {
+ data.len() >= 2 && data[0] == 0x3c && data[1] == 0x01
+}
+
+/// Queries the dongle once for the headset link state. Returns
+/// `Ok(Disconnected)` if no response arrives within the timeout window.
+pub fn link_status(hid: &mut Hidraw) -> io::Result<LinkState> {
+ hid.write(&link_query_frame())?;
+ let deadline = Instant::now() + LINK_RESPONSE_TIMEOUT;
+ while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
+ match hid.read_with_timeout(remaining)? {
+ Some(data) if is_link_response(&data) => return Ok(LinkState::Connected),
+ Some(_) => continue,
+ None => break,
+ }
+ }
+ Ok(LinkState::Disconnected)
+}
+
+/// Tracks link state across polls, debouncing disconnects and reporting
+/// transitions. State starts unknown (`None`) and is pinned by the first
+/// confirmed determination; `fire_initial` controls whether that first
+/// determination is reported as a transition.
+#[derive(Debug)]
+pub struct StateTracker {
+ state: Option<LinkState>,
+ misses: u32,
+ fire_initial: bool,
+}
+
+impl StateTracker {
+ pub fn new(fire_initial: bool) -> StateTracker {
+ StateTracker {
+ state: None,
+ misses: 0,
+ fire_initial,
+ }
+ }
+
+ /// Current confirmed state, `None` until the first determination.
+ pub fn state(&self) -> Option<LinkState> {
+ self.state
+ }
+
+ /// Feed one poll result (`true` = link response seen) and get any
+ /// confirmed transition.
+ pub fn update(&mut self, link_up: bool) -> Option<Transition> {
+ if link_up {
+ self.misses = 0;
+ if self.state == Some(LinkState::Connected) {
+ return None;
+ }
+ let initial = self.state.is_none();
+ self.state = Some(LinkState::Connected);
+ if initial && !self.fire_initial {
+ None
+ } else {
+ Some(Transition::ToConnected { initial })
+ }
+ } else {
+ self.misses += 1;
+ if self.misses < LINK_DEBOUNCE_MISSES || self.state == Some(LinkState::Disconnected) {
+ return None;
+ }
+ let initial = self.state.is_none();
+ self.state = Some(LinkState::Disconnected);
+ if initial && !self.fire_initial {
+ None
+ } else {
+ Some(Transition::ToDisconnected { initial })
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn link_query_frame_shape() {
+ let f = link_query_frame();
+ assert_eq!(f.len(), 61);
+ assert_eq!(&f[..4], &[0x3a, 0x09, 0x22, 0x01]);
+ assert!(f[4..].iter().all(|&b| b == 0));
+ }
+
+ #[test]
+ fn link_response_accepted() {
+ let r = [0x3c, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
+ assert!(is_link_response(&r));
+ }
+
+ #[test]
+ fn link_response_rejects_other_report_ids() {
+ let mut r = vec![0x3c, 0x01, 0x00, 0x00, 0x00, 0x01];
+ r[0] = 0x2c;
+ assert!(!is_link_response(&r));
+ assert!(!is_link_response(&[0x3d, 0x01]));
+ }
+
+ #[test]
+ fn link_response_rejects_wrong_echo() {
+ let r = [0x3c, 0x09, 0x00, 0x00, 0x00, 0x01];
+ assert!(!is_link_response(&r));
+ }
+
+ #[test]
+ fn link_response_rejects_short_frames() {
+ assert!(!is_link_response(&[]));
+ assert!(!is_link_response(&[0x3c]));
+ }
+
+ #[test]
+ fn initial_connect_fires() {
+ let mut t = StateTracker::new(true);
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: true })
+ );
+ assert_eq!(t.state(), Some(LinkState::Connected));
+ }
+
+ #[test]
+ fn initial_disconnect_fires_after_debounce() {
+ let mut t = StateTracker::new(true);
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.state(), None);
+ assert_eq!(
+ t.update(false),
+ Some(Transition::ToDisconnected { initial: true })
+ );
+ assert_eq!(t.state(), Some(LinkState::Disconnected));
+ }
+
+ #[test]
+ fn single_miss_does_not_disconnect() {
+ let mut t = StateTracker::new(true);
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: true })
+ );
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.update(true), None);
+ assert_eq!(t.state(), Some(LinkState::Connected));
+ }
+
+ #[test]
+ fn misses_reset_on_hit() {
+ let mut t = StateTracker::new(true);
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: true })
+ );
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.update(true), None);
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.update(true), None);
+ assert_eq!(t.state(), Some(LinkState::Connected));
+ }
+
+ #[test]
+ fn two_misses_disconnect_then_reconnect() {
+ let mut t = StateTracker::new(true);
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: true })
+ );
+ assert_eq!(t.update(false), None);
+ assert_eq!(
+ t.update(false),
+ Some(Transition::ToDisconnected { initial: false })
+ );
+ assert_eq!(t.update(false), None);
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: false })
+ );
+ }
+
+ #[test]
+ fn no_initial_fires_suppressed_but_later_fire() {
+ let mut t = StateTracker::new(false);
+ assert_eq!(t.update(true), None);
+ assert_eq!(t.state(), Some(LinkState::Connected));
+ assert_eq!(t.update(false), None);
+ assert_eq!(
+ t.update(false),
+ Some(Transition::ToDisconnected { initial: false })
+ );
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: false })
+ );
+ }
+
+ #[test]
+ fn suppressed_initial_still_tracks_state() {
+ let mut t = StateTracker::new(false);
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.update(false), None);
+ assert_eq!(t.state(), Some(LinkState::Disconnected));
+ assert_eq!(
+ t.update(true),
+ Some(Transition::ToConnected { initial: false })
+ );
+ }
+}