Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>` 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
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions crates/headset-device/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
27 changes: 22 additions & 5 deletions crates/headset-device/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
22 changes: 22 additions & 0 deletions crates/headset-tray/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(),
Expand Down
45 changes: 45 additions & 0 deletions crates/headset-tray/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ pub struct HeadsetState {
pub mic_mute_hardware: Option<bool>,
/// The Windows capture endpoint's mute, which is a separate state.
pub mic_mute_os: Option<bool>,
/// 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
Expand All @@ -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.
Expand Down Expand Up @@ -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}%")),
Expand Down Expand Up @@ -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();
Expand Down
75 changes: 69 additions & 6 deletions crates/headset-tray/src/ui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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());
Expand All @@ -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(),
Expand Down Expand Up @@ -1601,6 +1621,7 @@ mod tests {
mic_mute_hardware: Some(false),
mic_mute_os: Some(false),
warn_vendor_software: true,
dongle_silent: false,
}
}

Expand Down Expand Up @@ -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));
Expand Down
Loading