diff --git a/CHANGELOG.md b/CHANGELOG.md index b12a59e..2c9e964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0-alpha.4] - 2026-09-18 + +### Fixed + +- **A dongle that has stopped answering now says so, instead of reading SEARCHING forever.** + The dongle can wedge: it stays enumerated, Windows reports every interface healthy, the + control collection still resolves and scores correctly, and it answers nothing at all. + The tray had no way to describe that. `connected` was `Option` and `None` meant + only "not read yet", which the panel drew as SEARCHING — the same thing it shows in the + first second after launch — so a wedged dongle and a cold start looked identical, and + neither the header nor the tooltip ever mentioned the one thing that clears it. This is + not a guess about the cause: the first parameter the refresh asks for is answered by the + dongle out of its own state rather than proxied over the wireless link, and it answers + even with the headset powered off, so silence there is not "the headset is away" — it is + the dongle itself having gone quiet. The header now reads NOT RESPONDING · REPLUG DONGLE + and the tooltip says to unplug it and plug it back in. +- **Silence is no longer reported as a malformed response.** A request that went unanswered + came back as `ProtocolMismatch`, the error that means the device replied and the reply + was wrong. Two different conditions with different causes and different remedies shared + one variant, and the only way to tell them apart was to match on the text of the message, + so the tray did not try. Not answering at all is now its own error carrying the parameter, + the wait, and how many unrelated events arrived. The wording callers see is unchanged. +- **The device thread stopped hammering a dongle that was never going to answer.** A failed + refresh left the refresh timer untouched, so the worker re-ran the whole read sequence + every couple of seconds — each attempt blocking for a full exchange timeout — for as long + as the condition lasted, reporting it only at a log level nothing was listening to. + Retries now back off, and once the dongle is judged unresponsive they drop to one attempt + every fifteen seconds. + ## [0.2.0-alpha.3] - 2026-09-04 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1bde870..b87e0db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ [[package]] name = "headset-cli" -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" dependencies = [ "anyhow", "clap", @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "headset-device" -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" dependencies = [ "headset-protocol", "serde", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "headset-protocol" -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" dependencies = [ "serde", "thiserror", @@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "headset-tray" -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" dependencies = [ "headset-device", "headset-protocol", diff --git a/Cargo.toml b/Cargo.toml index c6bfc95..479cf14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" edition = "2021" # Matches the toolchain pinned in rust-toolchain.toml, which is what CI builds # and tests with. Not an independently verified floor: no job builds against an diff --git a/crates/headset-device/src/error.rs b/crates/headset-device/src/error.rs index 493164e..22fd0a2 100644 --- a/crates/headset-device/src/error.rs +++ b/crates/headset-device/src/error.rs @@ -26,6 +26,24 @@ pub enum DeviceError { #[error("response failed validation: {0}")] ProtocolMismatch(String), + /// The device acknowledged nothing at all within the exchange window. + /// + /// Deliberately distinct from [`DeviceError::ProtocolMismatch`], which means + /// the device answered and the answer was wrong. Silence is a different + /// condition with a different cause and a different remedy, and folding the + /// two together left callers unable to tell them apart: the tray could only + /// match on an error string, so it treated a dongle that had stopped + /// talking as a device it had not finished finding. + #[error( + "no response for parameter {param:#04x} within {waited:?}; {events_seen} unrelated \ + event(s) arrived while waiting" + )] + NoResponse { + param: u8, + waited: std::time::Duration, + events_seen: usize, + }, + #[error("device firmware is not supported: {0}")] UnsupportedFirmware(String), diff --git a/crates/headset-device/src/session.rs b/crates/headset-device/src/session.rs index 3701233..234965c 100644 --- a/crates/headset-device/src/session.rs +++ b/crates/headset-device/src/session.rs @@ -195,11 +195,11 @@ impl ControlSession { } } - Err(DeviceError::ProtocolMismatch(format!( - "no response for parameter {param:#04x} within {:?}; {events_seen} unrelated \ - event(s) arrived while waiting", - self.exchange_timeout - ))) + Err(DeviceError::NoResponse { + param, + waited: self.exchange_timeout, + events_seen, + }) } /// Reads input reports until the deadline, collecting decoded events. @@ -349,6 +349,23 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("no response"), "{msg}"); assert!(msg.contains("0 unrelated event"), "{msg}"); + + // The variant matters as much as the wording. Silence used to arrive as + // `ProtocolMismatch`, which says the device answered wrongly, and a + // caller that wanted to act on "answered nothing" had no choice but to + // match on this string. + match err { + DeviceError::NoResponse { + param, + waited, + events_seen, + } => { + assert_eq!(param, Param::Battery.id()); + assert_eq!(waited, Duration::from_millis(300)); + assert_eq!(events_seen, 0); + } + other => panic!("silence should be NoResponse, got {other:?}"), + } } #[test] diff --git a/crates/headset-tray/src/main.rs b/crates/headset-tray/src/main.rs index f615300..88621d8 100644 --- a/crates/headset-tray/src/main.rs +++ b/crates/headset-tray/src/main.rs @@ -245,6 +245,7 @@ fn run_render_panel() { mic_mute_hardware: Some(false), mic_mute_os: Some(false), warn_vendor_software: true, + dongle_silent: false, }; let mut cases: Vec<(&str, HeadsetState, View, SliderParam)> = Vec::new(); @@ -286,6 +287,27 @@ fn run_render_panel() { SliderParam::Sidetone, )); + // The wedged dongle: the control channel is open and answering nothing, so + // every proxied value is unknown and the header carries the remedy. + let silent = HeadsetState { + device_name: base.device_name.clone(), + connected: None, + battery: None, + sidetone: None, + game_chat: None, + noise: None, + mic_mute_hardware: None, + mic_mute_os: None, + warn_vendor_software: false, + dongle_silent: true, + }; + cases.push(( + "dongle-not-responding", + silent, + View::Main, + SliderParam::GameChat, + )); + cases.push(( "settings", base.clone(), diff --git a/crates/headset-tray/src/state.rs b/crates/headset-tray/src/state.rs index 2d52087..63d61e1 100644 --- a/crates/headset-tray/src/state.rs +++ b/crates/headset-tray/src/state.rs @@ -32,6 +32,16 @@ pub struct HeadsetState { pub mic_mute_hardware: Option, /// The Windows capture endpoint's mute, which is a separate state. pub mic_mute_os: Option, + /// Set when the control channel opened but the dongle answered nothing. + /// + /// Distinct from `connected: None`, which says only that the link state has + /// not been read yet. `0x20` is answered by the dongle itself and returns a + /// value even with the headset powered off, so silence there is positive + /// evidence that the dongle has stopped talking rather than that the + /// headset is away. The remedy is a replug, and the tray has to say so: it + /// rendered this as SEARCHING indefinitely, which is the same thing it + /// shows while starting up and told the user nothing. + pub dongle_silent: bool, /// Whether to warn that Razer's engine is running and may contend for /// settings. This is detection **and** the user's preference combined: the /// warning is suppressed when they have turned it off, so a single flag is @@ -58,6 +68,7 @@ impl HeadsetState { self.game_chat = from.game_chat; self.noise = from.noise; self.mic_mute_hardware = from.mic_mute_hardware; + self.dongle_silent = from.dongle_silent; } /// This state as the panel should draw it while a noise write is in flight. @@ -138,6 +149,10 @@ impl HeadsetState { /// `NOTIFYICONDATAW::szTip`. pub fn tooltip(&self) -> String { let mut s = String::from("BlackShark V3 Pro"); + if self.dongle_silent { + s.push_str(" - dongle not responding; unplug it and plug it back in"); + return s; + } match (self.connected, self.battery) { (Some(false), _) => s.push_str(" - off"), (_, Some(b)) => s.push_str(&format!(" - battery {b}%")), @@ -167,6 +182,36 @@ mod tests { } } + #[test] + fn a_silent_dongle_tooltip_names_the_remedy() { + let s = HeadsetState { + dongle_silent: true, + ..HeadsetState::default() + }; + let tip = s.tooltip(); + assert!(tip.contains("not responding"), "{tip}"); + assert!(tip.contains("plug it back in"), "{tip}"); + assert!( + !tip.contains("battery unknown"), + "an unresponsive dongle should not report on a battery it cannot read: {tip}" + ); + assert!(tip.len() <= 127, "szTip limit: {}", tip.len()); + } + + #[test] + fn the_device_snapshot_carries_the_silent_flag() { + // The worker owns this field. If `apply_device_snapshot` does not copy + // it, the UI thread keeps its own stale `false` and the warning never + // appears -- the same class of bug the mic/vendor split documents. + let mut ui = HeadsetState::default(); + let worker = HeadsetState { + dongle_silent: true, + ..HeadsetState::default() + }; + ui.apply_device_snapshot(&worker); + assert!(ui.dongle_silent); + } + #[test] fn a_refused_value_leaves_the_field_unknown() { let mut s = HeadsetState::default(); diff --git a/crates/headset-tray/src/ui/layout.rs b/crates/headset-tray/src/ui/layout.rs index 7b32982..5978cc1 100644 --- a/crates/headset-tray/src/ui/layout.rs +++ b/crates/headset-tray/src/ui/layout.rs @@ -483,10 +483,20 @@ fn header(b: &mut Builder, state: &HeadsetState, view: View, y: &mut f32) { Align::Left, ); - let status = match state.connected { - Some(true) => format!("CONNECTED · {LINK_TYPE_LABEL}"), - Some(false) => "DISCONNECTED".to_string(), - None => "SEARCHING".to_string(), + // A silent dongle outranks the link state, because the link state is + // exactly what a silent dongle has stopped telling us. Reporting SEARCHING + // here is what made a wedge indistinguishable from a cold start. + let status = if state.dongle_silent { + // Short by necessity: this caption is letter-spaced and sits beside the + // gear button, so a remedy does not fit here without wrapping over the + // device name. The banner below carries it instead. + "NOT RESPONDING".to_string() + } else { + match state.connected { + Some(true) => format!("CONNECTED · {LINK_TYPE_LABEL}"), + Some(false) => "DISCONNECTED".to_string(), + None => "SEARCHING".to_string(), + } }; b.caption( Rect::new(MARGIN + 16.0, *y + 22.0, CONTENT_W - 60.0, 14.0), @@ -764,7 +774,17 @@ fn main_body( noise_section(b, state, y, level_track); // ---- warning banner ---------------------------------------------------- - if state.warn_vendor_software { + // A silent dongle outranks the Synapse warning. A caution that something + // may override these settings is noise when nothing can read or write them + // at all, and only one banner fits. + let banner_text = if state.dongle_silent { + Some("Dongle not responding. Unplug it and plug it back in.") + } else if state.warn_vendor_software { + Some("Synapse is running and may override these settings.") + } else { + None + }; + if let Some(message) = banner_text { let banner = Rect::new(MARGIN, *y, CONTENT_W, BANNER_H); b.card(banner, bg_banner(), border_banner()); warning_icon(b, banner.x + 18.0, banner.center_y(), text_muted()); @@ -775,7 +795,7 @@ fn main_body( banner.w - 44.0, banner.h - 16.0, ), - "Synapse is running and may override these settings.", + message, FS_BODY - 1.0, W_REGULAR, text_muted(), @@ -1601,6 +1621,7 @@ mod tests { mic_mute_hardware: Some(false), mic_mute_os: Some(false), warn_vendor_software: true, + dongle_silent: false, } } @@ -2197,6 +2218,48 @@ mod tests { } } + #[test] + fn a_silent_dongle_is_not_reported_as_searching() { + // The whole point of the state: SEARCHING is what the panel shows + // while starting up, so leaving a wedged dongle on it gave the user no + // way to tell a cold start from a dongle that had stopped answering. + let mut s = connected(); + s.connected = None; + s.dongle_silent = true; + let p = build(&s, View::Main, SliderParam::GameChat, None); + let text: Vec<&str> = p + .primitives + .iter() + .filter_map(|prim| match prim { + Primitive::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert!( + !text.contains(&"SEARCHING"), + "a silent dongle must not read as SEARCHING: {text:?}" + ); + assert!( + text.iter() + .any(|t| t.contains("Unplug it and plug it back in")), + "the panel should name the remedy: {text:?}" + ); + } + + #[test] + fn a_silent_dongle_outranks_a_stale_connected_reading() { + // `connected` may still hold the last value read before the dongle went + // quiet. Showing CONNECTED over a channel that answers nothing is the + // worst of the options. + let mut s = connected(); + s.dongle_silent = true; + let p = build(&s, View::Main, SliderParam::GameChat, None); + let has_connected = p.primitives.iter().any( + |prim| matches!(prim, Primitive::Text { text, .. } if text.starts_with("CONNECTED")), + ); + assert!(!has_connected, "a silent dongle must not read as CONNECTED"); + } + #[test] fn preview_overrides_the_device_value_while_dragging() { let p = build(&connected(), View::Main, SliderParam::GameChat, Some(17)); diff --git a/crates/headset-tray/src/worker.rs b/crates/headset-tray/src/worker.rs index 8fb70ec..2347d36 100644 --- a/crates/headset-tray/src/worker.rs +++ b/crates/headset-tray/src/worker.rs @@ -33,6 +33,19 @@ const LISTEN_SLICE: Duration = Duration::from_millis(500); /// only to heal a missed one, not to drive the UI. const FULL_REFRESH: Duration = Duration::from_secs(60); +/// Consecutive silent refreshes before the tray calls the dongle unresponsive. +/// One missed exchange is ordinary. Three in a row on `0x20`, which the dongle +/// answers out of its own state rather than proxying to the headset, is not. +const SILENCE_THRESHOLD: u32 = 3; + +/// How soon to retry a refresh that failed without killing the session. +const RETRY_AFTER_FAILURE: Duration = Duration::from_secs(2); + +/// Retry interval once the dongle has been declared unresponsive. A wedged +/// dongle stays wedged until it is replugged and every attempt costs a full +/// exchange timeout, so asking constantly buys nothing and keeps a thread busy. +const SILENT_RETRY: Duration = Duration::from_secs(15); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Command { SetSidetone(u8), @@ -61,6 +74,12 @@ pub fn run( let mut state = HeadsetState::default(); let mut session: Option = None; let mut since_refresh = Duration::ZERO; + // How long to wait before the next `refresh_all`, which varies with the + // last outcome rather than being a single constant: a healthy device needs + // only the slow backstop, a transiently failing one should be retried + // sooner, and a silent one should be left mostly alone. + let mut refresh_due = Duration::ZERO; + let mut silent_reads: u32 = 0; loop { // (Re)establish the session. A failure here is normal, not fatal: the @@ -72,11 +91,17 @@ pub fn run( // resolves, so the header is right before any read lands. state.device_name = s.info().product.clone(); session = Some(s); - since_refresh = FULL_REFRESH; // force an immediate read + since_refresh = Duration::ZERO; + refresh_due = Duration::ZERO; // read immediately } Err(e) => { tracing::debug!("control session unavailable: {e}"); state.connected = None; + // Absent is not silent. There is no dongle to advise + // replugging, and leaving the flag set would keep that + // advice on screen after the dongle was pulled. + state.dongle_silent = false; + silent_reads = 0; notify(state.clone()); match commands.recv_timeout(Duration::from_secs(3)) { Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => return, @@ -87,10 +112,13 @@ pub fn run( } let Some(s) = session.as_mut() else { continue }; - if since_refresh >= FULL_REFRESH { + if since_refresh >= refresh_due { + since_refresh = Duration::ZERO; match refresh_all(s, &mut state) { Ok(()) => { - since_refresh = Duration::ZERO; + silent_reads = 0; + state.dongle_silent = false; + refresh_due = FULL_REFRESH; notify(state.clone()); } Err(e) if is_fatal(&e) => { @@ -98,7 +126,28 @@ pub fn run( session = None; continue; } - Err(e) => tracing::debug!("refresh failed: {e}"), + Err(e) => { + // Silence is the wedged-dongle signature and is reported. + // Any other non-fatal error is still just logged: it means + // the device answered, so it is talking. + if matches!(e, DeviceError::NoResponse { .. }) { + silent_reads = silent_reads.saturating_add(1); + if silent_reads >= SILENCE_THRESHOLD && !state.dongle_silent { + tracing::warn!( + "dongle answered none of the last {silent_reads} reads; \ + reporting it as unresponsive" + ); + state.dongle_silent = true; + notify(state.clone()); + } + } + refresh_due = if state.dongle_silent { + SILENT_RETRY + } else { + RETRY_AFTER_FAILURE + }; + tracing::debug!("refresh failed: {e}"); + } } }