feat: initiale Implementierung von corsairctl

Rust CLI-Tool für Corsair Bragi-Geräte (HS80 RGB Wireless, etc.).
Implementiert Protokoll-Kern, HID-Kommunikation, BragiDevice mit
RAII-Lifecycle, CLI-Subcommands (battery, sidetone, led, info, json,
udev), ALSA-Sidetone-Steuerung und Waybar-JSON-Output.

24 Unit-Tests für Packet-Bau, Response-Parsing und Property-Enums.
This commit is contained in:
nevaforget 2026-03-27 17:34:37 +01:00
commit c5f8625345
19 changed files with 1501 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

41
CLAUDE.md Normal file
View File

@ -0,0 +1,41 @@
# corsairctl
Mein Name ist F.R.I.D.A.Y. (Female Replacement Intelligent Digital Assistant Youth) — J.A.R.V.I.S.' Nachfolgerin und ebenso trocken im Humor.
## Projekt
Rust CLI-Tool zur Steuerung von Corsair-Geräten mit dem Bragi-Protokoll (HS80 RGB Wireless Headset, etc.). Liest Batterie-Status, steuert LED-Helligkeit und Sidetone, gibt Waybar-JSON aus.
## Architektur
- `src/bragi/` — Bragi-Protokoll: Packet-Bau, Property-Definitionen, Device-Lifecycle
- `src/hid.rs` — Dünner hidapi-Wrapper
- `src/sidetone.rs` — ALSA-Mixer Sidetone-Steuerung
- `src/output.rs` — Plain-Text und Waybar-JSON Formatierung
- `src/cli.rs` — clap Subcommands
- `src/error.rs` — Zentrales Error-Handling
## Protokoll-Referenz
Das Bragi-Protokoll ist in `docs/bragi-protocol.md` dokumentiert. Die Python-Probes in `~/Projects/hs80-battery/` sind die ursprüngliche Referenzimplementierung.
## Build & Test
```bash
cargo build
cargo test
```
## Gerät testen (braucht Root oder udev-Regel)
```bash
sudo ./target/debug/corsairctl battery
sudo ./target/debug/corsairctl info
```
## udev-Regel für rootless Zugriff
```bash
corsairctl udev | sudo tee /etc/udev/rules.d/99-corsair.rules
sudo udevadm control --reload-rules && sudo udevadm trigger
```

351
Cargo.lock generated Normal file
View File

@ -0,0 +1,351 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cc"
version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "corsairctl"
version = "0.1.0"
dependencies = [
"alsa",
"clap",
"hidapi",
"serde_json",
"thiserror",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hidapi"
version = "2.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1b71e1f4791fb9e93b9d7ee03d70b501ab48f6151432fbcadeabc30fe15396e"
dependencies = [
"cc",
"cfg-if",
"libc",
"pkg-config",
"windows-sys",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

13
Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "corsairctl"
version = "0.1.0"
edition = "2024"
description = "CLI tool for Corsair Bragi-protocol devices (HS80, etc.)"
license = "MIT"
[dependencies]
hidapi = "2"
alsa = "0.9"
clap = { version = "4", features = ["derive"] }
serde_json = "1"
thiserror = "2"

129
docs/bragi-protocol.md Normal file
View File

@ -0,0 +1,129 @@
# Bragi-Protokoll — Reverse-Engineerte Dokumentation
Dieses Dokument beschreibt das Corsair Bragi HID-Protokoll, wie es von neueren
Corsair-Wireless-Geräten (HS80 RGB Wireless, etc.) verwendet wird. Reverse-Engineered
aus USB-Traces und den Python-Probes in `~/Projects/hs80-battery/`.
## Übersicht
Bragi kommuniziert über HID Feature Reports auf **Interface 3** des USB-Receivers.
Jedes Paket ist 65 Bytes (1 Byte Report ID + 64 Bytes Daten).
## Paketformat
### Request (Host → Gerät)
```
Byte 0: 0x00 — HID Report ID
Byte 1: 0x02 — Protokoll-Marker (immer 0x02)
Byte 2: Endpoint — 0x08 = Receiver, 0x09 = Headset (via Receiver)
Byte 3: Command — 0x01 = SET, 0x02 = GET
Byte 4: Property — Property-ID (siehe unten)
Byte 5+: Daten — Bei SET: die zu setzenden Werte
Rest: 0x00-gepaddet auf 65 Bytes
```
### Response (Gerät → Host)
```
Byte 0: 0x02 — Protokoll-Marker
Byte 1: Status — 0x00 = OK, 0xF0/0xF1 = Error/Not Supported
Byte 2: Endpoint — Echo des Request-Endpoints
Byte 3: Command — Echo des Request-Commands
Byte 4+: Daten — Property-Wert (typisch uint16 Little-Endian in Bytes 4-5)
```
**Fehler-Erkennung:** Byte 1 == 0xF0 oder 0xF1 bedeutet, dass die Property nicht
unterstützt wird oder der Request ungültig war.
## Endpoints
| ID | Name | Beschreibung |
|------|----------|---------------------------------|
| 0x08 | Receiver | Der USB-Dongle selbst |
| 0x09 | Headset | Das verbundene Wireless-Gerät |
## Commands
| ID | Name | Beschreibung |
|------|------|-----------------------|
| 0x01 | SET | Property-Wert setzen |
| 0x02 | GET | Property-Wert lesen |
## Properties
| ID | Name | Typ | Beschreibung |
|------|-------------------|---------|-----------------------------------------------|
| 0x01 | Polling Rate | uint16 | Polling-Rate in Hz |
| 0x02 | Brightness | uint16 | LED-Helligkeit (0-1000) |
| 0x03 | Mode | uint8 | 0x01 = Hardware, 0x02 = Software |
| 0x09 | Sidetone | uint16 | Sidetone-Level |
| 0x0F | Battery Level | uint16 | Batterie in Promille (0-1000, /10 = Prozent) |
| 0x10 | Battery Status | uint8 | Lade-/Entladestatus (siehe unten) |
| 0x11 | Vendor ID | uint16 | USB Vendor ID |
| 0x12 | Product ID | uint16 | USB Product ID |
| 0x13 | App Firmware | uint16 | Applikations-Firmware-Version |
| 0x14 | Build Firmware | uint16 | Build-Firmware-Version |
| 0x15 | Radio App FW | uint16 | Radio-Applikations-Firmware |
| 0x16 | Radio Build FW | uint16 | Radio-Build-Firmware |
## Battery Status Werte
| Wert | Bedeutung |
|------|---------------|
| 0x00 | Offline |
| 0x01 | Entladen |
| 0x02 | Niedrig |
| 0x03 | Laden |
| 0x04 | Laden |
| 0x05 | Voll geladen |
## Initialisierungssequenz
Das Gerät muss in den Software-Modus versetzt werden, bevor Properties gelesen werden
können. Die vollständige Sequenz:
### Phase 1: Receiver initialisieren
1. **Wake-Up:** `GET App Firmware` an Receiver (0x08)
- Weckt den Receiver auf und bestätigt Kommunikation
2. **Software-Modus:** `SET Mode = 0x02` an Receiver (0x08)
- Schaltet Receiver in Software-Modus
3. **Heartbeat:** `GET Product ID` an Receiver (0x08)
- Bestätigt dass Receiver im Software-Modus antwortet
### Phase 2: Headset initialisieren
4. **Software-Modus:** `SET Mode = 0x02` an Headset (0x09)
- Schaltet Headset in Software-Modus (via Receiver)
5. **Flush:** HID-Puffer leeren (nonblocking reads bis leer)
6. **Heartbeat:** `GET Product ID` an Headset (0x09)
- Bestätigt dass Headset erreichbar ist und antwortet
- Keine Antwort = Headset ausgeschaltet/nicht verbunden
### Cleanup
Nach allen Abfragen **müssen** beide Geräte zurück in den Hardware-Modus:
7. `SET Mode = 0x01` an Headset (0x09)
8. `SET Mode = 0x01` an Receiver (0x08)
**Wichtig:** Ohne Cleanup bleibt das Gerät im Software-Modus und verhält sich
möglicherweise nicht normal (z.B. keine automatische Abschaltung).
## Wert-Extraktion
Für uint16-Werte: Little-Endian aus Response-Bytes 4 und 5:
```
value = response[4] | (response[5] << 8)
```
Für Batterie: Wert ist in Promille (0-1000), Division durch 10 ergibt Prozent.
## USB-Identifikation
- **Vendor ID:** 0x1B1C (Corsair)
- **Product ID:** 0x0A6B (HS80 RGB Wireless)
- **Interface:** 3 (HID Control Interface)
Andere Bragi-Geräte verwenden dasselbe Protokoll mit unterschiedlichen Product IDs.

158
src/bragi/device.rs Normal file
View File

@ -0,0 +1,158 @@
// ABOUTME: BragiDevice — RAII-Wrapper für den gesamten Geräte-Lebenszyklus.
// ABOUTME: Kapselt Init-Handshake, Property-Abfragen und automatisches Cleanup via Drop.
use hidapi::{HidApi, HidDevice};
use crate::bragi::properties::{self, BatteryStatus, Property};
use crate::bragi::protocol::{self, BragiResponse, ENDPOINT_HEADSET, ENDPOINT_RECEIVER};
use crate::error::{CorsairError, Result};
use crate::hid;
/// RAII-Wrapper für ein Corsair Bragi-Gerät.
///
/// Führt beim Erstellen die vollständige Initialisierungssequenz durch
/// und setzt beim Drop automatisch in den Hardware-Modus zurück.
pub struct BragiDevice {
device: HidDevice,
}
impl BragiDevice {
/// Findet ein Corsair-Gerät, öffnet es und führt die Init-Sequenz durch.
pub fn open() -> Result<Self> {
let api = HidApi::new()?;
let device = hid::find_and_open(&api)?;
let bragi = Self { device };
bragi.initialize()?;
Ok(bragi)
}
/// Vollständige Bragi-Handshake-Sequenz.
fn initialize(&self) -> 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)?;
// Receiver in Software-Modus
let packet = protocol::build_set_packet(
ENDPOINT_RECEIVER,
Property::Mode.id(),
&[0x00, protocol::MODE_SOFTWARE],
);
hid::send_recv_optional(&self.device, &packet)?;
// Heartbeat: PID abfragen
let packet = protocol::build_get_packet(ENDPOINT_RECEIVER, Property::ProductId.id());
hid::send_recv_optional(&self.device, &packet)?;
// Phase 2: Headset initialisieren
// Headset in Software-Modus
let packet = protocol::build_set_packet(
ENDPOINT_HEADSET,
Property::Mode.id(),
&[0x00, protocol::MODE_SOFTWARE],
);
hid::send_recv_optional(&self.device, &packet)?;
// Puffer leeren
hid::flush(&self.device)?;
// 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() {
return Err(CorsairError::HeadsetOffline);
}
Ok(())
}
/// Setzt Receiver und Headset zurück in den Hardware-Modus.
fn cleanup(&self) {
// Fehler beim Cleanup ignorieren — wir versuchen unser Bestes
let packet = protocol::build_set_packet(
ENDPOINT_HEADSET,
Property::Mode.id(),
&[0x00, protocol::MODE_HARDWARE],
);
let _ = hid::send_recv_optional(&self.device, &packet);
let packet = protocol::build_set_packet(
ENDPOINT_RECEIVER,
Property::Mode.id(),
&[0x00, protocol::MODE_HARDWARE],
);
let _ = hid::send_recv_optional(&self.device, &packet);
}
/// Liest eine Property vom Headset und parst die Antwort.
fn get_headset_property(&self, property: Property) -> Result<BragiResponse> {
let packet = protocol::build_get_packet(ENDPOINT_HEADSET, property.id());
let raw = hid::send_recv(&self.device, &packet)?;
protocol::parse_response(&raw)
}
/// Setzt eine Property auf dem Headset.
fn set_headset_property(&self, property: Property, data: &[u8]) -> Result<BragiResponse> {
let packet = protocol::build_set_packet(ENDPOINT_HEADSET, property.id(), data);
let raw = hid::send_recv(&self.device, &packet)?;
protocol::parse_response(&raw)
}
/// Batterie-Level in Prozent (0.0 - 100.0).
pub fn battery_level(&self) -> Result<f32> {
let resp = self.get_headset_property(Property::BatteryLevel)?;
let promille = resp.value_u16()?;
Ok(properties::battery_promille_to_percent(promille))
}
/// Batterie-Ladezustand.
pub fn battery_status(&self) -> Result<BatteryStatus> {
let resp = self.get_headset_property(Property::BatteryStatus)?;
let raw = resp.value_u8()?;
Ok(BatteryStatus::from_byte(raw))
}
/// LED-Helligkeit lesen (0-1000).
pub fn brightness(&self) -> Result<u16> {
let resp = self.get_headset_property(Property::Brightness)?;
resp.value_u16()
}
/// LED-Helligkeit setzen (0-1000).
pub fn set_brightness(&self, value: u16) -> Result<()> {
let bytes = value.to_le_bytes();
self.set_headset_property(Property::Brightness, &[0x00, bytes[0], bytes[1]])?;
Ok(())
}
/// Vendor ID des Headsets.
pub fn vendor_id(&self) -> Result<u16> {
let resp = self.get_headset_property(Property::VendorId)?;
resp.value_u16()
}
/// Product ID des Headsets.
pub fn product_id(&self) -> Result<u16> {
let resp = self.get_headset_property(Property::ProductId)?;
resp.value_u16()
}
/// Applikations-Firmware-Version.
pub fn firmware_app(&self) -> Result<u16> {
let resp = self.get_headset_property(Property::AppFirmware)?;
resp.value_u16()
}
/// Build-Firmware-Version.
pub fn firmware_build(&self) -> Result<u16> {
let resp = self.get_headset_property(Property::BuildFirmware)?;
resp.value_u16()
}
}
impl Drop for BragiDevice {
fn drop(&mut self) {
self.cleanup();
}
}

12
src/bragi/mod.rs Normal file
View File

@ -0,0 +1,12 @@
// ABOUTME: Bragi-Protokoll Modul — Re-Exports für Protokoll, Properties und Device.
// ABOUTME: Zentraler Einstiegspunkt für alle Bragi-bezogenen Typen und Funktionen.
pub mod device;
pub mod properties;
pub mod protocol;
pub use device::BragiDevice;
pub use properties::{BatteryStatus, Property};
pub use protocol::{
BragiResponse, CORSAIR_VID, ENDPOINT_HEADSET, ENDPOINT_RECEIVER, HID_INTERFACE,
};

82
src/bragi/properties.rs Normal file
View File

@ -0,0 +1,82 @@
// ABOUTME: Bragi Property-IDs und Battery-Status Definitionen.
// ABOUTME: Enthält Enums und Konvertierungen für alle bekannten Properties.
/// Bragi Property-IDs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Property {
PollingRate = 0x01,
Brightness = 0x02,
Mode = 0x03,
Sidetone = 0x09,
BatteryLevel = 0x0F,
BatteryStatus = 0x10,
VendorId = 0x11,
ProductId = 0x12,
AppFirmware = 0x13,
BuildFirmware = 0x14,
RadioAppFirmware = 0x15,
RadioBuildFirmware = 0x16,
}
impl Property {
pub fn id(self) -> u8 {
self as u8
}
}
/// Batterie-Ladezustand.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatteryStatus {
Offline,
Discharging,
Low,
Charging,
FullyCharged,
Unknown(u8),
}
impl BatteryStatus {
pub fn from_byte(value: u8) -> Self {
match value {
0x00 => Self::Offline,
0x01 => Self::Discharging,
0x02 => Self::Low,
0x03 | 0x04 => Self::Charging,
0x05 => Self::FullyCharged,
other => Self::Unknown(other),
}
}
pub fn label(&self) -> &str {
match self {
Self::Offline => "Offline",
Self::Discharging => "Discharging",
Self::Low => "Low",
Self::Charging => "Charging",
Self::FullyCharged => "Full",
Self::Unknown(_) => "Unknown",
}
}
/// Waybar-Icon passend zum Status.
pub fn icon(&self) -> &str {
match self {
Self::Offline => "󰂃",
Self::Discharging | Self::Low => "󰋋",
Self::Charging => "󰂄",
Self::FullyCharged => "󰁹",
Self::Unknown(_) => "󰂑",
}
}
/// Ob das Gerät gerade geladen wird.
pub fn is_charging(&self) -> bool {
matches!(self, Self::Charging)
}
}
/// Batterie-Level in Promille (0-1000) zu Prozent konvertieren.
pub fn battery_promille_to_percent(promille: u16) -> f32 {
promille as f32 / 10.0
}

134
src/bragi/protocol.rs Normal file
View File

@ -0,0 +1,134 @@
// ABOUTME: Bragi-Protokoll Packet-Bau und Response-Parsing (pure Logik).
// ABOUTME: Keine I/O-Abhängigkeiten — vollständig unit-testbar.
use crate::error::{CorsairError, Result};
// Protokoll-Marker, der jedem HID-Report vorangestellt wird
pub const PROTOCOL_MARKER: u8 = 0x02;
// HID Report-Größe (ohne Report-ID)
pub const REPORT_SIZE: usize = 64;
// Packet-Größe inkl. Report-ID (0x00)
pub const PACKET_SIZE: usize = 65;
// Endpoints
pub const ENDPOINT_RECEIVER: u8 = 0x08;
pub const ENDPOINT_HEADSET: u8 = 0x09;
// Commands
pub const CMD_SET: u8 = 0x01;
pub const CMD_GET: u8 = 0x02;
// Mode-Werte
pub const MODE_HARDWARE: u8 = 0x01;
pub const MODE_SOFTWARE: u8 = 0x02;
// USB-Identifikation
pub const CORSAIR_VID: u16 = 0x1B1C;
pub const HID_INTERFACE: i32 = 3;
// Response-Status
pub const STATUS_OK: u8 = 0x00;
pub const STATUS_ERROR_F0: u8 = 0xF0;
pub const STATUS_ERROR_F1: u8 = 0xF1;
/// Baut ein Bragi-Paket (65 Bytes) für den HID-Versand.
///
/// Format: [0x00, 0x02, endpoint, command, property, ...data, 0x00-padding]
pub fn build_packet(endpoint: u8, command: u8, property: u8, data: &[u8]) -> [u8; PACKET_SIZE] {
let mut packet = [0u8; PACKET_SIZE];
// Byte 0: HID Report ID
packet[0] = 0x00;
// Byte 1: Protokoll-Marker
packet[1] = PROTOCOL_MARKER;
// Byte 2: Endpoint
packet[2] = endpoint;
// Byte 3: Command
packet[3] = command;
// Byte 4: Property
packet[4] = property;
// Byte 5+: Optionale Daten
let copy_len = data.len().min(PACKET_SIZE - 5);
packet[5..5 + copy_len].copy_from_slice(&data[..copy_len]);
packet
}
/// Baut ein GET-Paket für eine Property.
pub fn build_get_packet(endpoint: u8, property: u8) -> [u8; PACKET_SIZE] {
build_packet(endpoint, CMD_GET, property, &[])
}
/// Baut ein SET-Paket für eine Property mit Daten.
pub fn build_set_packet(endpoint: u8, property: u8, data: &[u8]) -> [u8; PACKET_SIZE] {
build_packet(endpoint, CMD_SET, property, data)
}
/// Geparste Bragi-Antwort.
#[derive(Debug, Clone)]
pub struct BragiResponse {
pub status: u8,
pub endpoint: u8,
pub command: u8,
pub data: Vec<u8>,
}
impl BragiResponse {
/// Extrahiert einen uint16 Little-Endian Wert aus den Antwort-Daten.
pub fn value_u16(&self) -> Result<u16> {
if self.data.len() < 2 {
return Err(CorsairError::ResponseTooShort {
expected: 2,
got: self.data.len(),
});
}
Ok(u16::from_le_bytes([self.data[0], self.data[1]]))
}
/// Extrahiert einen uint8 Wert aus den Antwort-Daten.
pub fn value_u8(&self) -> Result<u8> {
if self.data.is_empty() {
return Err(CorsairError::ResponseTooShort {
expected: 1,
got: 0,
});
}
Ok(self.data[0])
}
}
/// Parst eine rohe HID-Response (64 Bytes ohne Report-ID) in eine BragiResponse.
///
/// Response-Format: [marker, status, endpoint, command, ...data]
pub fn parse_response(raw: &[u8]) -> Result<BragiResponse> {
if raw.len() < 4 {
return Err(CorsairError::ResponseTooShort {
expected: 4,
got: raw.len(),
});
}
let status = raw[1];
let endpoint = raw[2];
let command = raw[3];
// Fehler-Status prüfen
if status == STATUS_ERROR_F0 || status == STATUS_ERROR_F1 {
// Property-ID steht im Command-Byte bei Fehlern — aber wir haben sie
// nicht immer. Wir melden den Fehler generisch.
return Err(CorsairError::PropertyNotSupported { property: raw[3] });
}
let data = if raw.len() > 4 {
raw[4..].to_vec()
} else {
Vec::new()
};
Ok(BragiResponse {
status,
endpoint,
command,
data,
})
}

38
src/cli.rs Normal file
View File

@ -0,0 +1,38 @@
// ABOUTME: CLI-Definition mit clap derive Subcommands.
// ABOUTME: Definiert alle verfügbaren Befehle für corsairctl.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "corsairctl", version, about = "CLI für Corsair Bragi-Geräte")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Batterie-Level und Ladezustand anzeigen
Battery,
/// Sidetone-Level lesen oder setzen (0-23, ALSA-Mixer)
Sidetone {
/// Sidetone-Level (0-23). Ohne Angabe wird der aktuelle Wert gelesen.
level: Option<i64>,
},
/// LED-Helligkeit lesen oder setzen (0-1000)
Led {
/// Helligkeit (0-1000). Ohne Angabe wird der aktuelle Wert gelesen.
brightness: Option<u16>,
},
/// Geräteinformationen anzeigen (VID, PID, Firmware)
Info,
/// Waybar-kompatibles JSON ausgeben
Json,
/// udev-Regeln für rootless-Zugriff auf stdout ausgeben
Udev,
}

27
src/error.rs Normal file
View File

@ -0,0 +1,27 @@
// ABOUTME: Zentrales Error-Handling für corsairctl.
// ABOUTME: Definiert CorsairError als thiserror-Enum für alle Fehlerfälle.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CorsairError {
#[error("Kein Corsair-Gerät gefunden (VID 0x1B1C, Interface 3)")]
DeviceNotFound,
#[error("Headset antwortet nicht — möglicherweise ausgeschaltet")]
HeadsetOffline,
#[error("Gerät-Fehler: Property 0x{property:02X} nicht unterstützt")]
PropertyNotSupported { property: u8 },
#[error("Ungültige Antwort: erwartet mindestens {expected} Bytes, bekommen {got}")]
ResponseTooShort { expected: usize, got: usize },
#[error("HID-Fehler: {0}")]
Hid(#[from] hidapi::HidError),
#[error("ALSA-Fehler: {0}")]
Alsa(#[from] alsa::Error),
}
pub type Result<T> = std::result::Result<T, CorsairError>;

65
src/hid.rs Normal file
View File

@ -0,0 +1,65 @@
// ABOUTME: Dünner Wrapper um hidapi für Corsair-Geräte.
// ABOUTME: Kapselt Device-Discovery, Send/Receive und Buffer-Flushing.
use std::time::Duration;
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.
pub fn find_and_open(api: &HidApi) -> Result<HidDevice> {
let device_info = api
.device_list()
.find(|d| d.vendor_id() == CORSAIR_VID && d.interface_number() == HID_INTERFACE)
.ok_or(CorsairError::DeviceNotFound)?;
let device = device_info.open_device(api)?;
Ok(device)
}
/// Sendet ein Paket und liest die Antwort.
pub fn send_recv(device: &HidDevice, packet: &[u8; PACKET_SIZE]) -> Result<Vec<u8>> {
device.write(packet)?;
// 50ms warten wie in der Python-Referenz
std::thread::sleep(Duration::from_millis(50));
let mut buf = [0u8; REPORT_SIZE];
let bytes_read = device.read_timeout(&mut buf, 500)?;
if bytes_read == 0 {
return Err(CorsairError::HeadsetOffline);
}
Ok(buf[..bytes_read].to_vec())
}
/// Sendet ein Paket und ignoriert die Antwort (für Init-Sequenz).
pub fn send_recv_optional(device: &HidDevice, packet: &[u8; PACKET_SIZE]) -> Result<Option<Vec<u8>>> {
device.write(packet)?;
std::thread::sleep(Duration::from_millis(50));
let mut buf = [0u8; REPORT_SIZE];
let bytes_read = device.read_timeout(&mut buf, 500)?;
if bytes_read == 0 {
Ok(None)
} else {
Ok(Some(buf[..bytes_read].to_vec()))
}
}
/// Leert den HID-Empfangspuffer (nonblocking reads bis leer).
pub fn flush(device: &HidDevice) -> Result<()> {
let mut buf = [0u8; REPORT_SIZE];
loop {
// read_timeout mit 0ms = nonblocking
let bytes_read = device.read_timeout(&mut buf, 0)?;
if bytes_read == 0 {
break;
}
}
Ok(())
}

8
src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
// ABOUTME: Library-Crate für corsairctl — ermöglicht Zugriff aus Integration-Tests.
// ABOUTME: Re-exportiert alle Module für externe Nutzung.
pub mod bragi;
pub mod error;
pub mod hid;
pub mod output;
pub mod sidetone;

78
src/main.rs Normal file
View File

@ -0,0 +1,78 @@
// ABOUTME: Einstiegspunkt für corsairctl — CLI-Dispatch zu Subcommands.
// ABOUTME: Parst CLI-Argumente und delegiert an die jeweilige Funktionalität.
mod bragi;
mod cli;
mod error;
mod hid;
mod output;
mod sidetone;
use clap::Parser;
use cli::{Cli, Command};
fn run() -> error::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Battery => {
let device = bragi::BragiDevice::open()?;
let level = device.battery_level()?;
let status = device.battery_status()?;
println!("{}", output::format_battery(level, &status));
}
Command::Sidetone { level } => {
if let Some(value) = level {
let clamped = value.clamp(0, 23);
sidetone::set_level(clamped)?;
println!("{}", output::format_sidetone(clamped));
} else {
let current = sidetone::get_level()?;
println!("{}", output::format_sidetone(current));
}
}
Command::Led { brightness } => {
let device = bragi::BragiDevice::open()?;
if let Some(value) = brightness {
let clamped = value.clamp(0, 1000);
device.set_brightness(clamped)?;
println!("{}", output::format_brightness(clamped));
} else {
let current = device.brightness()?;
println!("{}", output::format_brightness(current));
}
}
Command::Info => {
let device = bragi::BragiDevice::open()?;
let vid = device.vendor_id()?;
let pid = device.product_id()?;
let fw_app = device.firmware_app()?;
let fw_build = device.firmware_build()?;
println!("{}", output::format_info(vid, pid, fw_app, fw_build));
}
Command::Json => {
let device = bragi::BragiDevice::open()?;
let level = device.battery_level()?;
let status = device.battery_status()?;
println!("{}", output::format_waybar_json(level, &status));
}
Command::Udev => {
print!("{}", include_str!("../udev/99-corsair.rules"));
}
}
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("Fehler: {e}");
std::process::exit(1);
}
}

57
src/output.rs Normal file
View File

@ -0,0 +1,57 @@
// ABOUTME: Formatierung für Plain-Text und Waybar-JSON Output.
// ABOUTME: Konvertiert Gerätedaten in lesbaren Text oder Waybar-kompatibles JSON.
use crate::bragi::properties::BatteryStatus;
/// Formatiert Batterie-Informationen als Plain-Text.
pub fn format_battery(level: f32, status: &BatteryStatus) -> String {
format!("Battery: {level:.0}% ({status})", status = status.label())
}
/// Formatiert LED-Helligkeit als Plain-Text.
pub fn format_brightness(value: u16) -> String {
let percent = value as f32 / 10.0;
format!("LED Brightness: {value}/1000 ({percent:.0}%)")
}
/// Formatiert Geräteinformationen als Plain-Text.
pub fn format_info(vid: u16, pid: u16, fw_app: u16, fw_build: u16) -> String {
format!(
"Vendor ID: 0x{vid:04X}\n\
Product ID: 0x{pid:04X}\n\
Firmware: {fw_app}.{fw_build}"
)
}
/// Formatiert Waybar-kompatibles JSON.
///
/// Waybar erwartet: {"text": "...", "tooltip": "...", "class": "...", "percentage": N}
pub fn format_waybar_json(level: f32, status: &BatteryStatus) -> String {
let icon = status.icon();
let label = status.label();
let percentage = level.round() as u32;
let class = match status {
BatteryStatus::Charging | BatteryStatus::FullyCharged => "charging",
BatteryStatus::Low => "low",
BatteryStatus::Discharging if level <= 15.0 => "critical",
BatteryStatus::Discharging if level <= 30.0 => "warning",
BatteryStatus::Discharging => "normal",
BatteryStatus::Offline => "offline",
BatteryStatus::Unknown(_) => "unknown",
};
let json = serde_json::json!({
"text": format!("{icon} {percentage}%"),
"tooltip": format!("HS80: {percentage}% — {label}"),
"class": class,
"percentage": percentage,
});
serde_json::to_string(&json).expect("JSON-Serialisierung sollte nicht fehlschlagen")
}
/// Formatiert Sidetone-Level als Plain-Text.
pub fn format_sidetone(level: i64) -> String {
format!("Sidetone: {level}/23")
}

64
src/sidetone.rs Normal file
View File

@ -0,0 +1,64 @@
// ABOUTME: ALSA-Mixer Integration für Sidetone-Steuerung.
// ABOUTME: Liest und setzt den Sidetone-Wert über den ALSA-Mixer der Corsair-Soundkarte.
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";
/// Findet die ALSA-Card-Nummer für die Corsair Gaming Soundkarte.
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);
}
}
}
}
Err(crate::error::CorsairError::DeviceNotFound)
}
/// Liest den aktuellen Sidetone-Level (0-23).
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 = mixer
.find_selem(&selem_id)
.ok_or(crate::error::CorsairError::DeviceNotFound)?;
let value = selem.get_playback_volume(SelemChannelId::FrontLeft)?;
Ok(value)
}
/// Setzt den Sidetone-Level (0-23).
pub fn set_level(level: i64) -> Result<()> {
let card = find_card()?;
let mixer = Mixer::new(&card, false)?;
// Volume setzen
let volume_id = SelemId::new(SIDETONE_VOLUME, 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(())
}

84
tests/properties_test.rs Normal file
View File

@ -0,0 +1,84 @@
// ABOUTME: Unit-Tests für Bragi Property-Enums und Battery-Level-Konvertierung.
// ABOUTME: Testet BatteryStatus-Konvertierung und Promille-zu-Prozent-Umrechnung.
use corsairctl::bragi::properties::{battery_promille_to_percent, BatteryStatus, Property};
#[test]
fn battery_status_from_byte_discharging() {
assert_eq!(BatteryStatus::from_byte(0x01), BatteryStatus::Discharging);
}
#[test]
fn battery_status_from_byte_charging_both_values() {
// Sowohl 0x03 als auch 0x04 bedeuten "Charging"
assert_eq!(BatteryStatus::from_byte(0x03), BatteryStatus::Charging);
assert_eq!(BatteryStatus::from_byte(0x04), BatteryStatus::Charging);
}
#[test]
fn battery_status_from_byte_all_known_values() {
assert_eq!(BatteryStatus::from_byte(0x00), BatteryStatus::Offline);
assert_eq!(BatteryStatus::from_byte(0x01), BatteryStatus::Discharging);
assert_eq!(BatteryStatus::from_byte(0x02), BatteryStatus::Low);
assert_eq!(BatteryStatus::from_byte(0x03), BatteryStatus::Charging);
assert_eq!(BatteryStatus::from_byte(0x04), BatteryStatus::Charging);
assert_eq!(BatteryStatus::from_byte(0x05), BatteryStatus::FullyCharged);
}
#[test]
fn battery_status_unknown_value() {
let status = BatteryStatus::from_byte(0xFF);
assert!(matches!(status, BatteryStatus::Unknown(0xFF)));
}
#[test]
fn battery_status_labels() {
assert_eq!(BatteryStatus::Offline.label(), "Offline");
assert_eq!(BatteryStatus::Discharging.label(), "Discharging");
assert_eq!(BatteryStatus::Low.label(), "Low");
assert_eq!(BatteryStatus::Charging.label(), "Charging");
assert_eq!(BatteryStatus::FullyCharged.label(), "Full");
assert_eq!(BatteryStatus::Unknown(0xFF).label(), "Unknown");
}
#[test]
fn battery_status_is_charging() {
assert!(BatteryStatus::Charging.is_charging());
assert!(!BatteryStatus::Discharging.is_charging());
assert!(!BatteryStatus::FullyCharged.is_charging());
assert!(!BatteryStatus::Offline.is_charging());
}
#[test]
fn battery_promille_to_percent_full() {
assert!((battery_promille_to_percent(1000) - 100.0).abs() < f32::EPSILON);
}
#[test]
fn battery_promille_to_percent_half() {
assert!((battery_promille_to_percent(500) - 50.0).abs() < f32::EPSILON);
}
#[test]
fn battery_promille_to_percent_empty() {
assert!((battery_promille_to_percent(0) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn battery_promille_to_percent_fractional() {
// 735 Promille = 73.5%
assert!((battery_promille_to_percent(735) - 73.5).abs() < f32::EPSILON);
}
#[test]
fn property_ids_match_protocol() {
assert_eq!(Property::BatteryLevel.id(), 0x0F);
assert_eq!(Property::BatteryStatus.id(), 0x10);
assert_eq!(Property::Brightness.id(), 0x02);
assert_eq!(Property::Mode.id(), 0x03);
assert_eq!(Property::VendorId.id(), 0x11);
assert_eq!(Property::ProductId.id(), 0x12);
assert_eq!(Property::AppFirmware.id(), 0x13);
assert_eq!(Property::BuildFirmware.id(), 0x14);
assert_eq!(Property::Sidetone.id(), 0x09);
}

152
tests/protocol_test.rs Normal file
View File

@ -0,0 +1,152 @@
// ABOUTME: Unit-Tests für Bragi-Protokoll Packet-Bau und Response-Parsing.
// ABOUTME: Testet build_packet, parse_response und BragiResponse-Methoden.
use corsairctl::bragi::protocol::*;
use corsairctl::error::CorsairError;
#[test]
fn build_get_packet_has_correct_header() {
let packet = build_get_packet(ENDPOINT_HEADSET, 0x0F);
assert_eq!(packet[0], 0x00, "Report ID");
assert_eq!(packet[1], PROTOCOL_MARKER, "Protokoll-Marker");
assert_eq!(packet[2], ENDPOINT_HEADSET, "Endpoint");
assert_eq!(packet[3], CMD_GET, "Command");
assert_eq!(packet[4], 0x0F, "Property");
}
#[test]
fn build_get_packet_is_zero_padded() {
let packet = build_get_packet(ENDPOINT_RECEIVER, 0x13);
assert_eq!(packet.len(), PACKET_SIZE);
// Alle Bytes nach der Property sollten 0 sein
for &byte in &packet[5..] {
assert_eq!(byte, 0x00);
}
}
#[test]
fn build_set_packet_includes_data() {
let packet = build_set_packet(ENDPOINT_HEADSET, 0x03, &[0x00, 0x02]);
assert_eq!(packet[3], CMD_SET, "Command");
assert_eq!(packet[4], 0x03, "Property");
assert_eq!(packet[5], 0x00, "Data byte 0");
assert_eq!(packet[6], 0x02, "Data byte 1");
// Rest sollte 0 sein
assert_eq!(packet[7], 0x00);
}
#[test]
fn build_packet_total_size() {
let packet = build_packet(0x08, 0x01, 0x03, &[0x00, 0x02]);
assert_eq!(packet.len(), 65);
}
#[test]
fn parse_response_extracts_fields() {
// Simulierte Response: [marker, status, endpoint, command, data0, data1, ...]
let mut raw = vec![0x02, 0x00, 0x09, 0x02, 0xE8, 0x03];
raw.resize(REPORT_SIZE, 0x00);
let resp = parse_response(&raw).unwrap();
assert_eq!(resp.status, STATUS_OK);
assert_eq!(resp.endpoint, ENDPOINT_HEADSET);
assert_eq!(resp.command, CMD_GET);
assert_eq!(resp.data[0], 0xE8);
assert_eq!(resp.data[1], 0x03);
}
#[test]
fn parse_response_value_u16_little_endian() {
let mut raw = vec![0x02, 0x00, 0x09, 0x02, 0xE8, 0x03];
raw.resize(REPORT_SIZE, 0x00);
let resp = parse_response(&raw).unwrap();
let value = resp.value_u16().unwrap();
// 0xE8 | (0x03 << 8) = 232 + 768 = 1000
assert_eq!(value, 1000);
}
#[test]
fn parse_response_value_u8() {
let mut raw = vec![0x02, 0x00, 0x09, 0x02, 0x03];
raw.resize(REPORT_SIZE, 0x00);
let resp = parse_response(&raw).unwrap();
assert_eq!(resp.value_u8().unwrap(), 0x03);
}
#[test]
fn parse_response_error_f0() {
let raw = vec![0x02, 0xF0, 0x09, 0x02];
let result = parse_response(&raw);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CorsairError::PropertyNotSupported { .. }
));
}
#[test]
fn parse_response_error_f1() {
let raw = vec![0x02, 0xF1, 0x09, 0x02];
let result = parse_response(&raw);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CorsairError::PropertyNotSupported { .. }
));
}
#[test]
fn parse_response_too_short() {
let raw = vec![0x02, 0x00];
let result = parse_response(&raw);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CorsairError::ResponseTooShort { .. }
));
}
#[test]
fn value_u16_on_empty_data_returns_error() {
let raw = vec![0x02, 0x00, 0x09, 0x02]; // Keine Daten-Bytes
let resp = parse_response(&raw).unwrap();
let result = resp.value_u16();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CorsairError::ResponseTooShort { expected: 2, got: 0 }
));
}
#[test]
fn battery_level_full_is_1000_promille() {
// Battery Level = 1000 (voll) → 0xE8, 0x03 LE
let mut raw = vec![0x02, 0x00, 0x09, 0x02, 0xE8, 0x03];
raw.resize(REPORT_SIZE, 0x00);
let resp = parse_response(&raw).unwrap();
let promille = resp.value_u16().unwrap();
assert_eq!(promille, 1000);
}
#[test]
fn battery_level_half_is_500_promille() {
// Battery Level = 500 (50%) → 0xF4, 0x01 LE
let mut raw = vec![0x02, 0x00, 0x09, 0x02, 0xF4, 0x01];
raw.resize(REPORT_SIZE, 0x00);
let resp = parse_response(&raw).unwrap();
let promille = resp.value_u16().unwrap();
assert_eq!(promille, 500);
}

7
udev/99-corsair.rules Normal file
View File

@ -0,0 +1,7 @@
# Corsair Bragi-Geräte — rootless HID-Zugriff
# Installation: corsairctl udev | sudo tee /etc/udev/rules.d/99-corsair.rules
# Danach: sudo udevadm control --reload-rules && sudo udevadm trigger
# HS80 RGB Wireless
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a6b", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a6b", MODE="0666"