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:
commit
c5f8625345
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal 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
351
Cargo.lock
generated
Normal 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
13
Cargo.toml
Normal 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
129
docs/bragi-protocol.md
Normal 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
158
src/bragi/device.rs
Normal 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
12
src/bragi/mod.rs
Normal 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
82
src/bragi/properties.rs
Normal 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
134
src/bragi/protocol.rs
Normal 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
38
src/cli.rs
Normal 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
27
src/error.rs
Normal 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
65
src/hid.rs
Normal 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
8
src/lib.rs
Normal 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
78
src/main.rs
Normal 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
57
src/output.rs
Normal 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
64
src/sidetone.rs
Normal 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
84
tests/properties_test.rs
Normal 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
152
tests/protocol_test.rs
Normal 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
7
udev/99-corsair.rules
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user