From c5f8625345f2fb072d8eb0092bff92475f109584 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Fri, 27 Mar 2026 17:34:37 +0100 Subject: [PATCH] feat: initiale Implementierung von corsairctl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + CLAUDE.md | 41 +++++ Cargo.lock | 351 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 13 ++ docs/bragi-protocol.md | 129 ++++++++++++++ src/bragi/device.rs | 158 ++++++++++++++++++ src/bragi/mod.rs | 12 ++ src/bragi/properties.rs | 82 +++++++++ src/bragi/protocol.rs | 134 +++++++++++++++ src/cli.rs | 38 +++++ src/error.rs | 27 +++ src/hid.rs | 65 ++++++++ src/lib.rs | 8 + src/main.rs | 78 +++++++++ src/output.rs | 57 +++++++ src/sidetone.rs | 64 +++++++ tests/properties_test.rs | 84 ++++++++++ tests/protocol_test.rs | 152 +++++++++++++++++ udev/99-corsair.rules | 7 + 19 files changed, 1501 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 docs/bragi-protocol.md create mode 100644 src/bragi/device.rs create mode 100644 src/bragi/mod.rs create mode 100644 src/bragi/properties.rs create mode 100644 src/bragi/protocol.rs create mode 100644 src/cli.rs create mode 100644 src/error.rs create mode 100644 src/hid.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/output.rs create mode 100644 src/sidetone.rs create mode 100644 tests/properties_test.rs create mode 100644 tests/protocol_test.rs create mode 100644 udev/99-corsair.rules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8c99394 --- /dev/null +++ b/CLAUDE.md @@ -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 +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..da07e1c --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b3bb56f --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/docs/bragi-protocol.md b/docs/bragi-protocol.md new file mode 100644 index 0000000..4e9dfe0 --- /dev/null +++ b/docs/bragi-protocol.md @@ -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. diff --git a/src/bragi/device.rs b/src/bragi/device.rs new file mode 100644 index 0000000..908b4b4 --- /dev/null +++ b/src/bragi/device.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let resp = self.get_headset_property(Property::VendorId)?; + resp.value_u16() + } + + /// Product ID des Headsets. + pub fn product_id(&self) -> Result { + let resp = self.get_headset_property(Property::ProductId)?; + resp.value_u16() + } + + /// Applikations-Firmware-Version. + pub fn firmware_app(&self) -> Result { + let resp = self.get_headset_property(Property::AppFirmware)?; + resp.value_u16() + } + + /// Build-Firmware-Version. + pub fn firmware_build(&self) -> Result { + let resp = self.get_headset_property(Property::BuildFirmware)?; + resp.value_u16() + } +} + +impl Drop for BragiDevice { + fn drop(&mut self) { + self.cleanup(); + } +} diff --git a/src/bragi/mod.rs b/src/bragi/mod.rs new file mode 100644 index 0000000..235f1e7 --- /dev/null +++ b/src/bragi/mod.rs @@ -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, +}; diff --git a/src/bragi/properties.rs b/src/bragi/properties.rs new file mode 100644 index 0000000..93d1088 --- /dev/null +++ b/src/bragi/properties.rs @@ -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 +} diff --git a/src/bragi/protocol.rs b/src/bragi/protocol.rs new file mode 100644 index 0000000..aa59ef0 --- /dev/null +++ b/src/bragi/protocol.rs @@ -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, +} + +impl BragiResponse { + /// Extrahiert einen uint16 Little-Endian Wert aus den Antwort-Daten. + pub fn value_u16(&self) -> Result { + 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 { + 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 { + 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, + }) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b8ead13 --- /dev/null +++ b/src/cli.rs @@ -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, + }, + + /// LED-Helligkeit lesen oder setzen (0-1000) + Led { + /// Helligkeit (0-1000). Ohne Angabe wird der aktuelle Wert gelesen. + brightness: Option, + }, + + /// Geräteinformationen anzeigen (VID, PID, Firmware) + Info, + + /// Waybar-kompatibles JSON ausgeben + Json, + + /// udev-Regeln für rootless-Zugriff auf stdout ausgeben + Udev, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..da19c6f --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; diff --git a/src/hid.rs b/src/hid.rs new file mode 100644 index 0000000..1c34a6e --- /dev/null +++ b/src/hid.rs @@ -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 { + 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> { + 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>> { + 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(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7e5fdef --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..02df391 --- /dev/null +++ b/src/main.rs @@ -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); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..c02e065 --- /dev/null +++ b/src/output.rs @@ -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") +} diff --git a/src/sidetone.rs b/src/sidetone.rs new file mode 100644 index 0000000..b9fc037 --- /dev/null +++ b/src/sidetone.rs @@ -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 { + // 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 { + 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(()) +} diff --git a/tests/properties_test.rs b/tests/properties_test.rs new file mode 100644 index 0000000..5bfe925 --- /dev/null +++ b/tests/properties_test.rs @@ -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); +} diff --git a/tests/protocol_test.rs b/tests/protocol_test.rs new file mode 100644 index 0000000..f67db58 --- /dev/null +++ b/tests/protocol_test.rs @@ -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); +} diff --git a/udev/99-corsair.rules b/udev/99-corsair.rules new file mode 100644 index 0000000..c3fcc52 --- /dev/null +++ b/udev/99-corsair.rules @@ -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"