fix: Device-Discovery und Sidetone gegen echte Hardware korrigiert

- PID-Filter in find_and_open(), damit nicht die Harpoon-Maus statt
  dem HS80 geöffnet wird
- ALSA Sidetone: Card-Suche über Sidetone-Control statt Kartenname
  (war abgeschnitten zu "Gamin")
- Verbose-Flag (-v) für Debug-Output der Init-Sequenz
This commit is contained in:
nevaforget 2026-03-27 22:10:40 +01:00
parent c5f8625345
commit 812d14b81a
5 changed files with 111 additions and 37 deletions

View File

@ -19,19 +19,33 @@ pub struct BragiDevice {
impl BragiDevice {
/// Findet ein Corsair-Gerät, öffnet es und führt die Init-Sequenz durch.
pub fn open() -> Result<Self> {
Self::open_with_verbose(false)
}
/// Wie `open()`, aber mit optionalem Debug-Output auf stderr.
pub fn open_with_verbose(verbose: bool) -> Result<Self> {
let api = HidApi::new()?;
if verbose {
hid::log_device_info(&api);
}
let device = hid::find_and_open(&api)?;
if verbose {
eprintln!("[init] Gerät gefunden und geöffnet");
}
let bragi = Self { device };
bragi.initialize()?;
bragi.initialize(verbose)?;
Ok(bragi)
}
/// Vollständige Bragi-Handshake-Sequenz.
fn initialize(&self) -> Result<()> {
fn initialize(&self, verbose: bool) -> Result<()> {
// Phase 1: Receiver initialisieren
// Wake-Up: Firmware-Version lesen
let packet = protocol::build_get_packet(ENDPOINT_RECEIVER, Property::AppFirmware.id());
hid::send_recv_optional(&self.device, &packet)?;
let resp = hid::send_recv_optional(&self.device, &packet)?;
if verbose {
eprintln!("[init] Receiver Wake-Up: {}", format_resp(&resp));
}
// Receiver in Software-Modus
let packet = protocol::build_set_packet(
@ -39,11 +53,17 @@ impl BragiDevice {
Property::Mode.id(),
&[0x00, protocol::MODE_SOFTWARE],
);
hid::send_recv_optional(&self.device, &packet)?;
let resp = hid::send_recv_optional(&self.device, &packet)?;
if verbose {
eprintln!("[init] Receiver -> Software-Mode: {}", format_resp(&resp));
}
// Heartbeat: PID abfragen
let packet = protocol::build_get_packet(ENDPOINT_RECEIVER, Property::ProductId.id());
hid::send_recv_optional(&self.device, &packet)?;
let resp = hid::send_recv_optional(&self.device, &packet)?;
if verbose {
eprintln!("[init] Receiver PID (Heartbeat): {}", format_resp(&resp));
}
// Phase 2: Headset initialisieren
// Headset in Software-Modus
@ -52,15 +72,24 @@ impl BragiDevice {
Property::Mode.id(),
&[0x00, protocol::MODE_SOFTWARE],
);
hid::send_recv_optional(&self.device, &packet)?;
let resp = hid::send_recv_optional(&self.device, &packet)?;
if verbose {
eprintln!("[init] Headset -> Software-Mode: {}", format_resp(&resp));
}
// Puffer leeren
hid::flush(&self.device)?;
if verbose {
eprintln!("[init] Buffer geflusht");
}
// Heartbeat: PID abfragen — keine Antwort = Headset offline
let packet = protocol::build_get_packet(ENDPOINT_HEADSET, Property::ProductId.id());
let response = hid::send_recv_optional(&self.device, &packet)?;
if response.is_none() {
let resp = hid::send_recv_optional(&self.device, &packet)?;
if verbose {
eprintln!("[init] Headset PID (Heartbeat): {}", format_resp(&resp));
}
if resp.is_none() {
return Err(CorsairError::HeadsetOffline);
}
@ -151,6 +180,18 @@ impl BragiDevice {
}
}
/// Formatiert eine optionale Response für Debug-Output.
fn format_resp(resp: &Option<Vec<u8>>) -> String {
match resp {
Some(data) => {
let hex: Vec<String> = data.iter().take(20).map(|b| format!("{b:02X}")).collect();
let suffix = if data.len() > 20 { "..." } else { "" };
format!("{}{}", hex.join(" "), suffix)
}
None => "keine Antwort".to_string(),
}
}
impl Drop for BragiDevice {
fn drop(&mut self) {
self.cleanup();

View File

@ -6,6 +6,10 @@ use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "corsairctl", version, about = "CLI für Corsair Bragi-Geräte")]
pub struct Cli {
/// Debug-Output auf stderr ausgeben
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}

View File

@ -8,17 +8,55 @@ use hidapi::{HidApi, HidDevice};
use crate::bragi::protocol::{CORSAIR_VID, HID_INTERFACE, PACKET_SIZE, REPORT_SIZE};
use crate::error::{CorsairError, Result};
/// Findet ein Corsair-Gerät auf Interface 3 und öffnet es.
/// Findet ein Corsair Bragi-Gerät auf Interface 3 und öffnet es.
///
/// Sucht gezielt nach bekannten Bragi-PIDs, um Verwechslungen mit
/// anderen Corsair-Geräten (z.B. Mäuse) zu vermeiden.
pub fn find_and_open(api: &HidApi) -> Result<HidDevice> {
let device_info = api
// Alle Corsair-Geräte auf Interface 3 sammeln
let candidates: Vec<_> = api
.device_list()
.find(|d| d.vendor_id() == CORSAIR_VID && d.interface_number() == HID_INTERFACE)
.filter(|d| d.vendor_id() == CORSAIR_VID && d.interface_number() == HID_INTERFACE)
.collect();
if candidates.is_empty() {
return Err(CorsairError::DeviceNotFound);
}
// Bekannte Bragi-PIDs bevorzugen (HS80 = 0x0A6B)
let device_info = candidates
.iter()
.find(|d| KNOWN_BRAGI_PIDS.contains(&d.product_id()))
.or(candidates.first())
.ok_or(CorsairError::DeviceNotFound)?;
let device = device_info.open_device(api)?;
Ok(device)
}
/// Gibt Informationen über das gefundene Gerät aus (für Debug).
pub fn log_device_info(api: &HidApi) {
let candidates: Vec<_> = api
.device_list()
.filter(|d| d.vendor_id() == CORSAIR_VID && d.interface_number() == HID_INTERFACE)
.collect();
for d in &candidates {
let selected = if KNOWN_BRAGI_PIDS.contains(&d.product_id()) { " ← selected" } else { "" };
eprintln!(
"[hid] Gefunden: VID=0x{:04X} PID=0x{:04X} Interface={}{selected}",
d.vendor_id(),
d.product_id(),
d.interface_number(),
);
}
}
/// Bekannte Product IDs für Corsair Bragi-Geräte.
const KNOWN_BRAGI_PIDS: &[u16] = &[
0x0A6B, // HS80 RGB Wireless
];
/// Sendet ein Paket und liest die Antwort.
pub fn send_recv(device: &HidDevice, packet: &[u8; PACKET_SIZE]) -> Result<Vec<u8>> {
device.write(packet)?;

View File

@ -17,7 +17,7 @@ fn run() -> error::Result<()> {
match cli.command {
Command::Battery => {
let device = bragi::BragiDevice::open()?;
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
let level = device.battery_level()?;
let status = device.battery_status()?;
println!("{}", output::format_battery(level, &status));
@ -35,7 +35,7 @@ fn run() -> error::Result<()> {
}
Command::Led { brightness } => {
let device = bragi::BragiDevice::open()?;
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
if let Some(value) = brightness {
let clamped = value.clamp(0, 1000);
device.set_brightness(clamped)?;
@ -47,7 +47,7 @@ fn run() -> error::Result<()> {
}
Command::Info => {
let device = bragi::BragiDevice::open()?;
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
let vid = device.vendor_id()?;
let pid = device.product_id()?;
let fw_app = device.firmware_app()?;
@ -56,7 +56,7 @@ fn run() -> error::Result<()> {
}
Command::Json => {
let device = bragi::BragiDevice::open()?;
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
let level = device.battery_level()?;
let status = device.battery_status()?;
println!("{}", output::format_waybar_json(level, &status));

View File

@ -5,21 +5,19 @@ use alsa::mixer::{Mixer, SelemChannelId, SelemId};
use crate::error::Result;
const CARD_NAME: &str = "Gaming";
const SIDETONE_VOLUME: &str = "Sidetone Playback Volume";
const SIDETONE_SWITCH: &str = "Sidetone Playback Switch";
const SIDETONE_CONTROL: &str = "Sidetone";
/// Findet die ALSA-Card-Nummer für die Corsair Gaming Soundkarte.
/// Findet die ALSA-Card mit einem Sidetone-Control.
///
/// Sucht nach der Karte, die ein "Sidetone" Control hat,
/// statt sich auf den Kartennamen zu verlassen (der kann abgeschnitten sein).
fn find_card() -> Result<String> {
// ALSA-Cards durchiterieren
for card_idx in 0..32 {
let name = format!("hw:{card_idx}");
if let Ok(ctl) = alsa::Ctl::new(&name, false) {
if let Ok(info) = ctl.card_info() {
let card_name = info.get_name().unwrap_or_default();
if card_name.contains(CARD_NAME) {
return Ok(name);
}
for card in alsa::card::Iter::new().flatten() {
let name = format!("hw:{}", card.get_index());
if let Ok(mixer) = Mixer::new(&name, false) {
let selem_id = SelemId::new(SIDETONE_CONTROL, 0);
if mixer.find_selem(&selem_id).is_some() {
return Ok(name);
}
}
}
@ -31,7 +29,7 @@ pub fn get_level() -> Result<i64> {
let card = find_card()?;
let mixer = Mixer::new(&card, false)?;
let selem_id = SelemId::new(SIDETONE_VOLUME, 0);
let selem_id = SelemId::new(SIDETONE_CONTROL, 0);
let selem = mixer
.find_selem(&selem_id)
.ok_or(crate::error::CorsairError::DeviceNotFound)?;
@ -46,19 +44,12 @@ pub fn set_level(level: i64) -> Result<()> {
let mixer = Mixer::new(&card, false)?;
// Volume setzen
let volume_id = SelemId::new(SIDETONE_VOLUME, 0);
let volume_id = SelemId::new(SIDETONE_CONTROL, 0);
let volume_elem = mixer
.find_selem(&volume_id)
.ok_or(crate::error::CorsairError::DeviceNotFound)?;
volume_elem.set_playback_volume_all(level)?;
// Switch aktivieren falls Level > 0
let switch_id = SelemId::new(SIDETONE_SWITCH, 0);
if let Some(switch_elem) = mixer.find_selem(&switch_id) {
let enabled = if level > 0 { 1 } else { 0 };
let _ = switch_elem.set_playback_switch_all(enabled);
}
Ok(())
}