10 Commits

Author SHA1 Message Date
nevaforget 01149d7a60 docs: translate CLAUDE.md to English
Per the committed=English rule.
2026-06-16 10:46:13 +02:00
nevaforget b7e42f0911 ci: add update-pkgver workflow (tag-trigger) 2026-06-10 18:41:37 +02:00
nevaforget baada36222 fix: reject out-of-range led brightness instead of silently clamping
led accepted any u16 and main.rs clamped to 0..=1000 with no feedback,
inconsistent with sidetone which rejects out-of-range at parse time.
Add a clap range validator (0..=1000) and drop the silent clamp, so
invalid input now fails loudly.

Further quality-audit follow-ups:
- remove dead BragiDevice::open() (no caller; binary uses open_with_verbose)
- add tests: led range validation, format_battery for all status
  variants, waybar "unknown" class

Bump 0.1.2 -> 0.1.3.
2026-06-10 17:51:40 +02:00
nevaforget e364f4edec chore: bump version to 0.1.2
Sync Cargo.toml to 0.1.2 and bring PKGBUILD up to date (was stale at
0.1.0): pin the git source to the release tag for reproducible builds
and install the LICENSE file.
2026-06-10 16:41:42 +02:00
nevaforget 8a9f31f1b4 chore: add MIT license, record private-use scope
Add MIT LICENSE file (Cargo.toml already declares MIT). Record in
DECISIONS.md that the open-source publish/rename plan is dropped: the
tool is for personal use, distributed as an Arch package via the private
moonarch repo and used primarily as a Waybar widget.
2026-06-10 16:38:10 +02:00
nevaforget 0ebecccc1b docs: translate Bragi protocol reference to English
Translate docs/bragi-protocol.md from German to English ahead of
open-source publication. Drop the local reference path and note
additional Bragi devices (HS65, Virtuoso SE).
2026-06-10 16:37:08 +02:00
nevaforget c38996b8a4 docs: add publish plan and decision log
PLAN.md: 5-phase plan to publish as a multi-protocol headset tool
(rename away from Corsair trademark, abstract device layer, port
additional protocols, publish, upstream to HeadsetControl).

DECISIONS.md: rationale for GPLv3 relicense, naming constraints, and
the multi-protocol architecture.
2026-06-10 16:37:08 +02:00
nevaforget 914ddf114e refactor: consume library crate from binary
main.rs declared its own module tree (mod bragi, cli, ...) while lib.rs
re-exported the same modules. The binary recompiled everything without
using the library, producing 9 spurious dead-code warnings for items
that are actually exercised by the integration tests via the library.

Import from the corsairctl library instead. No functional change;
eliminates all 9 warnings.
2026-06-10 16:37:08 +02:00
nevaforget 098b53744d chore: update Cargo.lock for v0.1.1 2026-04-09 17:17:20 +02:00
nevaforget 960bc60b20 feat: show sidetone level in Waybar tooltip
Tooltip now shows "HS80: 42% — Discharging | Sidetone: 10/23" when
the ALSA sidetone control is available. Falls back gracefully to
battery-only tooltip when sidetone cannot be read.

Bump version to 0.1.1.
2026-04-09 17:13:41 +02:00
15 changed files with 430 additions and 130 deletions
+43
View File
@@ -0,0 +1,43 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds when a new corsairctl tag is pushed.
# ABOUTME: Reads the latest version tag and bumps the PKGBUILD + .SRCINFO.
name: Update PKGBUILD version
on:
push:
tags:
- 'v*'
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Determine pkgver from latest tag
run: |
git clone --bare http://gitea:3000/nevaforget/corsairctl.git source.git
cd source.git
PKGVER=$(git describe --tags --abbrev=0 | sed 's/^v//')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
run: |
PKGVER=$(cat /tmp/pkgver)
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' corsairctl/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" corsairctl/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" corsairctl/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add corsairctl/PKGBUILD corsairctl/.SRCINFO
git commit -m "chore(corsairctl): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
+15 -15
View File
@@ -1,23 +1,23 @@
# corsairctl # 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. My name is F.R.I.D.A.Y. (Female Replacement Intelligent Digital Assistant Youth) — J.A.R.V.I.S.' successor and just as dry in humor.
## Projekt ## Project
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. Rust CLI tool to control Corsair devices using the Bragi protocol (HS80 RGB Wireless headset, etc.). Reads battery status, controls LED brightness and sidetone, outputs Waybar JSON.
## Architektur ## Architecture
- `src/bragi/` — Bragi-Protokoll: Packet-Bau, Property-Definitionen, Device-Lifecycle - `src/bragi/` — Bragi protocol: packet building, property definitions, device lifecycle
- `src/hid.rs`Dünner hidapi-Wrapper - `src/hid.rs`thin hidapi wrapper
- `src/sidetone.rs` — ALSA-Mixer Sidetone-Steuerung - `src/sidetone.rs` — ALSA mixer sidetone control
- `src/output.rs`Plain-Text und Waybar-JSON Formatierung - `src/output.rs`plain-text and Waybar-JSON formatting
- `src/cli.rs` — clap Subcommands - `src/cli.rs` — clap subcommands
- `src/error.rs`Zentrales Error-Handling - `src/error.rs`central error handling
## Protokoll-Referenz ## Protocol Reference
Das Bragi-Protokoll ist in `docs/bragi-protocol.md` dokumentiert. Die Python-Probes in `~/Projects/hs80-battery/` sind die ursprüngliche Referenzimplementierung. The Bragi protocol is documented in `docs/bragi-protocol.md`. The Python probes in `~/Projects/hs80-battery/` are the original reference implementation.
## Build & Test ## Build & Test
@@ -26,16 +26,16 @@ cargo build
cargo test cargo test
``` ```
## Gerät testen (braucht Root oder udev-Regel) ## Testing the device (needs root or a udev rule)
```bash ```bash
sudo ./target/debug/corsairctl battery sudo ./target/debug/corsairctl battery
sudo ./target/debug/corsairctl info sudo ./target/debug/corsairctl info
``` ```
## udev-Regel für rootless Zugriff ## udev rule for rootless access
Nutzt `TAG+="uaccess"` — gibt dem am Seat eingeloggten User automatisch Zugriff, ohne Gruppen-Setup. Uses `TAG+="uaccess"` — gives the user logged in at the seat automatic access, without group setup.
```bash ```bash
corsairctl udev | sudo tee /etc/udev/rules.d/99-corsair.rules corsairctl udev | sudo tee /etc/udev/rules.d/99-corsair.rules
Generated
+5 -5
View File
@@ -82,9 +82,9 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.58" version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -144,7 +144,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "corsairctl" name = "corsairctl"
version = "0.1.0" version = "0.1.3"
dependencies = [ dependencies = [
"alsa", "alsa",
"clap", "clap",
@@ -192,9 +192,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]] [[package]]
name = "memchr" name = "memchr"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "corsairctl" name = "corsairctl"
version = "0.1.0" version = "0.1.3"
edition = "2024" edition = "2024"
description = "CLI tool for Corsair Bragi-protocol devices (HS80, etc.)" description = "CLI tool for Corsair Bragi-protocol devices (HS80, etc.)"
license = "MIT" license = "MIT"
+25
View File
@@ -0,0 +1,25 @@
# Decisions
## 2026-04-09 License: GPLv3 instead of MIT
- **Who**: Dom, F.R.I.D.A.Y.
- **Why**: Plan to support multiple headset protocols using HeadsetControl (GPLv3) as reference for protocol implementations. HeadsetControl's code is community-tested — reimplementing blind without hardware to test against is impractical. GPL copyleft is acceptable since the tool is open source on GitHub anyway.
- **Tradeoffs**: MIT would allow proprietary use, but we gain access to tested protocol implementations. GPL has no practical downside for a CLI tool distributed as source.
- **How**: GPLv3-or-later in Cargo.toml and LICENSE file.
## 2026-04-09 Tool name: must avoid "Corsair" trademark
- **Who**: Dom, F.R.I.D.A.Y.
- **Why**: "Corsair" is a registered trademark. Publishing under `corsairctl` risks trademark issues. "Bragi" is also risky (Bragi GmbH, Danish audio company).
- **Tradeoffs**: `headsetctl` is descriptive and follows Linux naming conventions (`bluetoothctl`, `brightnessctl`). Less specific but safer.
- **How**: Rename TBD — to be decided as part of the publish plan.
## 2026-04-09 Multi-protocol architecture using HeadsetControl as reference
- **Who**: Dom, F.R.I.D.A.Y.
- **Why**: Each headset vendor uses a proprietary HID protocol. No common standard exists. HeadsetControl already has tested implementations for SteelSeries, Logitech, HyperX, Corsair, etc.
- **Tradeoffs**: Porting C code to Rust takes effort, but the protocol logic is the hard part (byte sequences, timing, quirks) — and that's already documented in HeadsetControl's source.
- **How**: Modular backend architecture — each protocol as a separate module behind a common trait. Bragi is the first backend; others follow.
## 2026-06-10 Scope: private use only, revert to MIT (supersedes the three 2026-04-09 decisions)
- **Who**: Dom, F.R.I.D.A.Y.
- **Why**: The open-source publish plan (rename away from "Corsair", multi-protocol backend layer, crates.io, upstream to HeadsetControl) was dropped. The tool is for personal use across Dom's own machines, distributed as an Arch package via the private moonarch repo (Gitea registry at gitea.moonarch.de), used primarily as a Waybar widget.
- **Tradeoffs**: GPLv3 and the rename were only justified by public distribution and porting HeadsetControl (GPLv3) code. With no public release and no GPL-derived code in the tree, copyleft and the trademark rename buy nothing. MIT is simpler and reversible — switch to GPLv3 only if GPL code is later pulled in.
- **How**: Revert license to MIT (Cargo.toml + MIT LICENSE file). Keep the name `corsairctl`. PLAN.md retained as a record of the considered direction, no longer driving work.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dominik Kressler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: nevaforget <dom@moonarch.de> # Maintainer: nevaforget <dom@moonarch.de>
pkgname=corsairctl pkgname=corsairctl
pkgver=0.1.0 pkgver=0.1.2
pkgrel=1 pkgrel=1
pkgdesc='CLI tool for Corsair Bragi-protocol devices (HS80 RGB Wireless, etc.)' pkgdesc='CLI tool for Corsair Bragi-protocol devices (HS80 RGB Wireless, etc.)'
arch=('x86_64') arch=('x86_64')
@@ -8,7 +8,7 @@ url='https://gitea.moonarch.de/nevaforget/corsairctl'
license=('MIT') license=('MIT')
depends=('hidapi' 'alsa-lib') depends=('hidapi' 'alsa-lib')
makedepends=('cargo' 'pkg-config') makedepends=('cargo' 'pkg-config')
source=("git+${url}.git") source=("git+${url}.git#tag=v$pkgver")
sha256sums=('SKIP') sha256sums=('SKIP')
prepare() { prepare() {
@@ -29,4 +29,5 @@ package() {
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname" install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
install -Dm755 "scripts/corsairctl-waybar.sh" "$pkgdir/usr/bin/corsairctl-waybar" install -Dm755 "scripts/corsairctl-waybar.sh" "$pkgdir/usr/bin/corsairctl-waybar"
install -Dm644 "udev/99-corsair.rules" "$pkgdir/usr/lib/udev/rules.d/99-corsair.rules" install -Dm644 "udev/99-corsair.rules" "$pkgdir/usr/lib/udev/rules.d/99-corsair.rules"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+152
View File
@@ -0,0 +1,152 @@
# Plan: Publish as Multi-Protocol Headset Tool
## Status: DRAFT — awaiting review
## Goal
Publish `corsairctl` as an open-source, multi-headset CLI tool under GPLv3.
Rename away from "Corsair" trademark. Support additional headset protocols
using HeadsetControl (C, GPLv3) as reference.
---
## Phase 1: Rename & License (no functional changes)
1. **Pick a new name** — candidates:
- `headsetctl` (clear, follows Linux conventions: `brightnessctl`, `bluetoothctl`)
- `hsetctl` (shorter, less likely to collide)
- `openheadset` (explicit open-source angle)
- Decision needed before anything else — name affects crate name, binary, repo, docs
2. **License file** — ✅ Done (GPLv3 LICENSE + Cargo.toml updated)
3. **Rename crate & binary** — update `Cargo.toml` name, all references in code,
CLAUDE.md, README, udev rules, Waybar wrapper, PKGBUILD
4. **Add SPDX headers** — GPLv3 requires license notice in each source file
5. **Clean up repo** — there's a nested `src/corsairctl/` directory that looks like
an old copy of the entire repo (has its own `.git/`, `Cargo.toml`, `CLAUDE.md`).
Investigate and remove if it's dead weight.
---
## Phase 2: Abstract device layer (architecture)
Current state: `BragiDevice` directly uses `hidapi` and the Bragi protocol.
Everything is Corsair-specific (VID, PIDs, handshake).
Target state: a trait-based backend system so multiple protocols can coexist.
```
src/
├── backend/
│ ├── mod.rs // Headset trait + DeviceInfo struct
│ ├── bragi.rs // Corsair Bragi (current code, extracted)
│ ├── steelseries.rs // future
│ └── logitech.rs // future
├── hid.rs // generic HID helpers (send/recv/flush)
├── cli.rs
├── output.rs
├── error.rs
└── main.rs
```
### The `Headset` trait
```rust
pub trait Headset {
fn name(&self) -> &str;
fn battery_level(&self) -> Result<f32>;
fn battery_status(&self) -> Result<BatteryStatus>;
fn brightness(&self) -> Option<Result<u16>>; // not all headsets have LEDs
fn set_brightness(&self, value: u16) -> Option<Result<()>>;
fn sidetone(&self) -> Option<Result<u8>>; // not all have sidetone
fn set_sidetone(&self, value: u8) -> Option<Result<()>>;
fn info(&self) -> Result<DeviceInfo>;
}
```
### Auto-detection
Scan HID devices, try each backend's `probe()` function:
```rust
pub fn detect() -> Result<Box<dyn Headset>> {
// Try Bragi first (checks Corsair VID + known PIDs)
// Then SteelSeries, Logitech, etc.
}
```
---
## Phase 3: Port first additional protocol
Pick the easiest one from HeadsetControl as proof of concept.
Good candidates (simple protocol, popular hardware):
- **SteelSeries Arctis Nova 7** — straightforward HID, well-documented in HeadsetControl
- **Logitech G PRO X** — also relatively simple
Steps per protocol:
1. Study HeadsetControl's C implementation
2. Implement as a Rust module behind the `Headset` trait
3. Add VID/PID to auto-detection
4. Update udev rules generator
5. Test (needs hardware or community testers)
---
## Phase 4: Publish
1. **GitHub repo** — rename or create new repo
2. **README** — features, supported devices, install instructions
3. **crates.io** — publish crate
4. **AUR package** — update PKGBUILD
5. **HeadsetControl community** — announce, invite testers for untested protocols
---
## Phase 5: Upstream contribution to HeadsetControl
Contribute Bragi protocol support back to [Sapd/HeadsetControl](https://github.com/Sapd/HeadsetControl).
### Why this is feasible
HeadsetControl already has `corsair_void_v2w.hpp` which uses a near-identical
protocol pattern to Bragi: multi-step handshake (firmware query → software mode
→ heartbeat), receiver + headset endpoints, HID buffer flushing with 5ms timeout,
sidetone 0-1000 range. The `CorsairDevice` base class provides shared helpers
for sidetone mapping, battery parsing, and LED control.
### What a `corsair_bragi.hpp` would contain
- New class inheriting from `CorsairDevice`
- Bragi-specific PIDs: `0x0A6B` (HS80), potentially HS65, Virtuoso SE
- Our Bragi property ID set (from `properties.rs`)
- LED brightness support (0-1000, not in existing Corsair implementations)
- Init handshake adapted to Bragi's two-phase sequence (receiver → headset)
### Steps
1. Study `corsair_device.hpp` and `corsair_void_v2w.hpp` in detail
2. Write `corsair_bragi.hpp` in C++20, following their patterns
3. Test with our HS80 hardware
4. Open PR with protocol documentation (`docs/bragi-protocol.md`)
5. List all known Bragi PIDs we can find (community input helps here)
### Alternative: documentation-only contribution
If writing C++ is too much effort, contribute just the protocol documentation
as a PR. HeadsetControl's community can implement it from there. We already
have `docs/bragi-protocol.md` which covers the full protocol.
---
## Open Questions
- [ ] Final name?
- [ ] Should sidetone stay ALSA-based or also go through HID where supported?
- [ ] Minimum supported Rust version (MSRV)?
- [ ] Do we want a config file for per-device settings?
- [ ] The nested `src/corsairctl/` directory — is this an old copy or something active?
+76 -76
View File
@@ -1,136 +1,136 @@
# Bragi-Protokoll — Reverse-Engineerte Dokumentation # Bragi Protocol — Reverse-Engineered Documentation
Dieses Dokument beschreibt das Corsair Bragi HID-Protokoll, wie es von neueren This document describes the Corsair Bragi HID protocol used by newer Corsair
Corsair-Wireless-Geräten (HS80 RGB Wireless, etc.) verwendet wird. Reverse-Engineered wireless devices (HS80 RGB Wireless, HS65, Virtuoso SE, etc.). Reverse-engineered
aus USB-Traces und den Python-Probes in `~/Projects/hs80-battery/`. from USB traces and protocol analysis.
## Übersicht ## Overview
Bragi kommuniziert über HID Feature Reports auf **Interface 3** des USB-Receivers. Bragi communicates via HID Feature Reports on **Interface 3** of the USB receiver.
Jedes Paket ist 65 Bytes (1 Byte Report ID + 64 Bytes Daten). Each packet is 65 bytes (1 byte Report ID + 64 bytes data).
## Paketformat ## Packet Format
### Request (Host → Gerät) ### Request (Host → Device)
``` ```
Byte 0: 0x00 — HID Report ID Byte 0: 0x00 — HID Report ID
Byte 1: 0x02 — Protokoll-Marker (immer 0x02) Byte 1: 0x02 — Protocol marker (always 0x02)
Byte 2: Endpoint — 0x08 = Receiver, 0x09 = Headset (via Receiver) Byte 2: Endpoint — 0x08 = Receiver, 0x09 = Headset (via Receiver)
Byte 3: Command — 0x01 = SET, 0x02 = GET Byte 3: Command — 0x01 = SET, 0x02 = GET
Byte 4: Property — Property-ID (siehe unten) Byte 4: Property — Property ID (see below)
Byte 5+: DatenBei SET: die zu setzenden Werte Byte 5+: Data For SET: the values to write
Rest: 0x00-gepaddet auf 65 Bytes Rest: 0x00-padded to 65 bytes
``` ```
### Response (Gerät → Host) ### Response (Device → Host)
``` ```
Byte 0: 0x01 — Report-Typ (immer 0x01, NICHT 0x02 wie im Request) Byte 0: 0x01 — Report type (always 0x01, NOT 0x02 like the request marker)
Byte 1: Endpoint — 0x00 = Receiver, 0x01 = Headset (andere IDs als im Request!) Byte 1: Endpoint — 0x00 = Receiver, 0x01 = Headset (different IDs than request!)
Bei Fehler: 0xF0/0xF1 = Error/Not Supported On error: 0xF0/0xF1 = Error/Not Supported
Byte 2: Command — Echo: 0x01 = SET, 0x02 = GET Byte 2: Command — Echo: 0x01 = SET, 0x02 = GET
Byte 3: Status — 0x00 = OK Byte 3: Status — 0x00 = OK
Byte 4+: Daten — Property-Wert (typisch uint16 Little-Endian in Bytes 4-5) Byte 4+: Data — Property value (typically uint16 Little-Endian in bytes 4-5)
``` ```
**Achtung:** Das Response-Format weicht vom Request-Format ab: **Note:** The response format differs from the request format:
- Byte 0 ist `0x01` (nicht `0x02` wie der Request-Marker) - Byte 0 is `0x01` (not `0x02` like the request marker)
- Endpoint-IDs sind `0x00`/`0x01` (nicht `0x08`/`0x09` wie im Request) - Endpoint IDs are `0x00`/`0x01` (not `0x08`/`0x09` like in the request)
**Fehler-Erkennung:** Byte 1 == 0xF0 oder 0xF1 bedeutet, dass die Property nicht **Error detection:** Byte 1 == 0xF0 or 0xF1 indicates that the property is not
unterstützt wird oder der Request ungültig war. supported or the request was invalid.
## Endpoints ## Endpoints
| ID | Name | Beschreibung | | ID | Name | Description |
|------|----------|---------------------------------| |------|----------|---------------------------------|
| 0x08 | Receiver | Der USB-Dongle selbst | | 0x08 | Receiver | The USB dongle itself |
| 0x09 | Headset | Das verbundene Wireless-Gerät | | 0x09 | Headset | The connected wireless device |
## Commands ## Commands
| ID | Name | Beschreibung | | ID | Name | Description |
|------|------|-----------------------| |------|------|-----------------------|
| 0x01 | SET | Property-Wert setzen | | 0x01 | SET | Set a property value |
| 0x02 | GET | Property-Wert lesen | | 0x02 | GET | Read a property value |
## Properties ## Properties
| ID | Name | Typ | Beschreibung | | ID | Name | Type | Description |
|------|-------------------|---------|-----------------------------------------------| |------|-------------------|---------|-----------------------------------------------|
| 0x01 | Polling Rate | uint16 | Polling-Rate in Hz | | 0x01 | Polling Rate | uint16 | Polling rate in Hz |
| 0x02 | Brightness | uint16 | LED-Helligkeit (0-1000) | | 0x02 | Brightness | uint16 | LED brightness (0-1000) |
| 0x03 | Mode | uint8 | 0x01 = Hardware, 0x02 = Software | | 0x03 | Mode | uint8 | 0x01 = Hardware, 0x02 = Software |
| 0x09 | Sidetone | uint16 | Sidetone-Level | | 0x09 | Sidetone | uint16 | Sidetone level |
| 0x0F | Battery Level | uint16 | Batterie in Promille (0-1000, /10 = Prozent) | | 0x0F | Battery Level | uint16 | Battery in per-mille (0-1000, /10 = percent) |
| 0x10 | Battery Status | uint8 | Lade-/Entladestatus (siehe unten) | | 0x10 | Battery Status | uint8 | Charging/discharging status (see below) |
| 0x11 | Vendor ID | uint16 | USB Vendor ID | | 0x11 | Vendor ID | uint16 | USB Vendor ID |
| 0x12 | Product ID | uint16 | USB Product ID | | 0x12 | Product ID | uint16 | USB Product ID |
| 0x13 | App Firmware | uint16 | Applikations-Firmware-Version | | 0x13 | App Firmware | uint16 | Application firmware version |
| 0x14 | Build Firmware | uint16 | Build-Firmware-Version | | 0x14 | Build Firmware | uint16 | Build firmware version |
| 0x15 | Radio App FW | uint16 | Radio-Applikations-Firmware | | 0x15 | Radio App FW | uint16 | Radio application firmware |
| 0x16 | Radio Build FW | uint16 | Radio-Build-Firmware | | 0x16 | Radio Build FW | uint16 | Radio build firmware |
## Battery Status Werte ## Battery Status Values
Empirisch ermittelt am HS80 RGB Wireless (2026-03-27). Empirically determined on the HS80 RGB Wireless.
Weicht von ckb-next-Quellen ab — dort sind die Werte anders zugeordnet. Differs from ckb-next sources where values are mapped differently.
| Wert | Bedeutung | | Value | Meaning |
|------|---------------| |-------|---------------|
| 0x00 | Offline | | 0x00 | Offline |
| 0x01 | Laden | | 0x01 | Charging |
| 0x02 | Entladen | | 0x02 | Discharging |
| 0x03 | Niedrig | | 0x03 | Low |
| 0x04 | Voll geladen | | 0x04 | Fully charged |
## Initialisierungssequenz ## Initialization Sequence
Das Gerät muss in den Software-Modus versetzt werden, bevor Properties gelesen werden The device must be switched to software mode before properties can be read.
können. Die vollständige Sequenz: Full sequence:
### Phase 1: Receiver initialisieren ### Phase 1: Initialize Receiver
1. **Wake-Up:** `GET App Firmware` an Receiver (0x08) 1. **Wake-up:** `GET App Firmware` to Receiver (0x08)
- Weckt den Receiver auf und bestätigt Kommunikation - Wakes the receiver and confirms communication
2. **Software-Modus:** `SET Mode = 0x02` an Receiver (0x08) 2. **Software mode:** `SET Mode = 0x02` to Receiver (0x08)
- Schaltet Receiver in Software-Modus - Switches receiver to software mode
3. **Heartbeat:** `GET Product ID` an Receiver (0x08) 3. **Heartbeat:** `GET Product ID` to Receiver (0x08)
- Bestätigt dass Receiver im Software-Modus antwortet - Confirms receiver responds in software mode
### Phase 2: Headset initialisieren ### Phase 2: Initialize Headset
4. **Software-Modus:** `SET Mode = 0x02` an Headset (0x09) 4. **Software mode:** `SET Mode = 0x02` to Headset (0x09)
- Schaltet Headset in Software-Modus (via Receiver) - Switches headset to software mode (via receiver)
5. **Flush:** HID-Puffer leeren (nonblocking reads bis leer) 5. **Flush:** Clear HID buffer (non-blocking reads until empty)
6. **Heartbeat:** `GET Product ID` an Headset (0x09) 6. **Heartbeat:** `GET Product ID` to Headset (0x09)
- Bestätigt dass Headset erreichbar ist und antwortet - Confirms headset is reachable and responding
- Keine Antwort = Headset ausgeschaltet/nicht verbunden - No response = headset powered off / not connected
### Cleanup ### Cleanup
Nach allen Abfragen **müssen** beide Geräte zurück in den Hardware-Modus: After all queries, both devices **must** be switched back to hardware mode:
7. `SET Mode = 0x01` an Headset (0x09) 7. `SET Mode = 0x01` to Headset (0x09)
8. `SET Mode = 0x01` an Receiver (0x08) 8. `SET Mode = 0x01` to Receiver (0x08)
**Wichtig:** Ohne Cleanup bleibt das Gerät im Software-Modus und verhält sich **Important:** Without cleanup the device stays in software mode and may not
möglicherweise nicht normal (z.B. keine automatische Abschaltung). behave normally (e.g. no automatic power-off).
## Wert-Extraktion ## Value Extraction
Für uint16-Werte: Little-Endian aus Response-Bytes 4 und 5: For uint16 values: Little-Endian from response bytes 4 and 5:
``` ```
value = response[4] | (response[5] << 8) value = response[4] | (response[5] << 8)
``` ```
Für Batterie: Wert ist in Promille (0-1000), Division durch 10 ergibt Prozent. For battery: value is in per-mille (0-1000), divide by 10 for percent.
## USB-Identifikation ## USB Identification
- **Vendor ID:** 0x1B1C (Corsair) - **Vendor ID:** 0x1B1C (Corsair)
- **Product ID:** 0x0A6B (HS80 RGB Wireless) - **Product ID:** 0x0A6B (HS80 RGB Wireless)
- **Interface:** 3 (HID Control Interface) - **Interface:** 3 (HID Control Interface)
Andere Bragi-Geräte verwenden dasselbe Protokoll mit unterschiedlichen Product IDs. Other Bragi devices use the same protocol with different Product IDs.
+1 -5
View File
@@ -22,11 +22,7 @@ pub struct BragiDevice {
impl BragiDevice { impl BragiDevice {
/// Findet ein Corsair-Gerät, öffnet es und führt die Init-Sequenz durch. /// Findet ein Corsair-Gerät, öffnet es und führt die Init-Sequenz durch.
pub fn open() -> Result<Self> { /// Mit optionalem Debug-Output auf stderr.
Self::open_with_verbose(false)
}
/// Wie `open()`, aber mit optionalem Debug-Output auf stderr.
pub fn open_with_verbose(verbose: bool) -> Result<Self> { pub fn open_with_verbose(verbose: bool) -> Result<Self> {
let api = HidApi::new()?; let api = HidApi::new()?;
if verbose { if verbose {
+1
View File
@@ -29,6 +29,7 @@ pub enum Command {
/// LED-Helligkeit lesen oder setzen (0-1000) /// LED-Helligkeit lesen oder setzen (0-1000)
Led { Led {
/// Helligkeit (0-1000). Ohne Angabe wird der aktuelle Wert gelesen. /// Helligkeit (0-1000). Ohne Angabe wird der aktuelle Wert gelesen.
#[arg(value_parser = clap::value_parser!(u16).range(0..=1000))]
brightness: Option<u16>, brightness: Option<u16>,
}, },
+9 -12
View File
@@ -1,16 +1,13 @@
// ABOUTME: Einstiegspunkt für corsairctl — CLI-Dispatch zu Subcommands. // ABOUTME: Einstiegspunkt für corsairctl — CLI-Dispatch zu Subcommands.
// ABOUTME: Parst CLI-Argumente und delegiert an die jeweilige Funktionalität. // 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 clap::Parser;
use cli::{Cli, Command}; use corsairctl::bragi;
use corsairctl::cli::{Cli, Command};
use corsairctl::error;
use corsairctl::output;
use corsairctl::sidetone;
fn run() -> error::Result<()> { fn run() -> error::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -36,9 +33,8 @@ fn run() -> error::Result<()> {
Command::Led { brightness } => { Command::Led { brightness } => {
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?; let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
if let Some(value) = brightness { if let Some(value) = brightness {
let clamped = value.clamp(0, 1000); device.set_brightness(value)?;
device.set_brightness(clamped)?; println!("{}", output::format_brightness(value));
println!("{}", output::format_brightness(clamped));
} else { } else {
let current = device.brightness()?; let current = device.brightness()?;
println!("{}", output::format_brightness(current)); println!("{}", output::format_brightness(current));
@@ -58,7 +54,8 @@ fn run() -> error::Result<()> {
let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?; let device = bragi::BragiDevice::open_with_verbose(cli.verbose)?;
let level = device.battery_level()?; let level = device.battery_level()?;
let status = device.battery_status()?; let status = device.battery_status()?;
println!("{}", output::format_waybar_json(level, &status)); let sidetone = sidetone::get_level().ok();
println!("{}", output::format_waybar_json(level, &status, sidetone));
} }
Command::Udev => { Command::Udev => {
+7 -2
View File
@@ -26,7 +26,7 @@ pub fn format_info(vid: u16, pid: u16, fw_app: u16, fw_build: u16) -> String {
/// Formatiert Waybar-kompatibles JSON. /// Formatiert Waybar-kompatibles JSON.
/// ///
/// Waybar erwartet: {"text": "...", "tooltip": "...", "class": "...", "percentage": N} /// Waybar erwartet: {"text": "...", "tooltip": "...", "class": "...", "percentage": N}
pub fn format_waybar_json(level: f32, status: &BatteryStatus) -> String { pub fn format_waybar_json(level: f32, status: &BatteryStatus, sidetone: Option<i64>) -> String {
let icon = status.icon(); let icon = status.icon();
let label = status.label(); let label = status.label();
let percentage = level.round() as u32; let percentage = level.round() as u32;
@@ -41,9 +41,14 @@ pub fn format_waybar_json(level: f32, status: &BatteryStatus) -> String {
BatteryStatus::Unknown(_) => "unknown", BatteryStatus::Unknown(_) => "unknown",
}; };
let tooltip = match sidetone {
Some(st) => format!("HS80: {percentage}% — {label} | Sidetone: {st}/23"),
None => format!("HS80: {percentage}% — {label}"),
};
let json = serde_json::json!({ let json = serde_json::json!({
"text": format!("{icon} {percentage}%"), "text": format!("{icon} {percentage}%"),
"tooltip": format!("HS80: {percentage}% — {label}"), "tooltip": tooltip,
"class": class, "class": class,
"percentage": percentage, "percentage": percentage,
}); });
+12
View File
@@ -44,3 +44,15 @@ fn sidetone_without_value_is_read_mode() {
let result = parse_args(&["corsairctl", "sidetone"]); let result = parse_args(&["corsairctl", "sidetone"]);
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn led_rejects_above_1000() {
let result = parse_args(&["corsairctl", "led", "1001"]);
assert!(result.is_err());
}
#[test]
fn led_accepts_max() {
let result = parse_args(&["corsairctl", "led", "1000"]);
assert!(result.is_ok());
}
+59 -12
View File
@@ -16,6 +16,30 @@ fn format_battery_charging() {
assert_eq!(result, "Battery: 50% (Charging)"); assert_eq!(result, "Battery: 50% (Charging)");
} }
#[test]
fn format_battery_fully_charged() {
let result = output::format_battery(100.0, &BatteryStatus::FullyCharged);
assert_eq!(result, "Battery: 100% (Full)");
}
#[test]
fn format_battery_low() {
let result = output::format_battery(10.0, &BatteryStatus::Low);
assert_eq!(result, "Battery: 10% (Low)");
}
#[test]
fn format_battery_offline() {
let result = output::format_battery(0.0, &BatteryStatus::Offline);
assert_eq!(result, "Battery: 0% (Offline)");
}
#[test]
fn format_battery_unknown() {
let result = output::format_battery(50.0, &BatteryStatus::Unknown(0x99));
assert_eq!(result, "Battery: 50% (Unknown)");
}
#[test] #[test]
fn format_brightness_value() { fn format_brightness_value() {
let result = output::format_brightness(330); let result = output::format_brightness(330);
@@ -45,14 +69,14 @@ fn format_info_values() {
#[test] #[test]
fn waybar_json_is_valid_json() { fn waybar_json_is_valid_json() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("muss valides JSON sein"); let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("muss valides JSON sein");
assert!(parsed.is_object()); assert!(parsed.is_object());
} }
#[test] #[test]
fn waybar_json_has_required_fields() { fn waybar_json_has_required_fields() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(parsed["text"].is_string(), "text fehlt"); assert!(parsed["text"].is_string(), "text fehlt");
assert!(parsed["tooltip"].is_string(), "tooltip fehlt"); assert!(parsed["tooltip"].is_string(), "tooltip fehlt");
@@ -62,56 +86,56 @@ fn waybar_json_has_required_fields() {
#[test] #[test]
fn waybar_json_percentage_matches_level() { fn waybar_json_percentage_matches_level() {
let json_str = output::format_waybar_json(73.5, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(73.5, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["percentage"], 74); // 73.5 rounded assert_eq!(parsed["percentage"], 74); // 73.5 rounded
} }
#[test] #[test]
fn waybar_json_class_charging() { fn waybar_json_class_charging() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Charging); let json_str = output::format_waybar_json(50.0, &BatteryStatus::Charging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "charging"); assert_eq!(parsed["class"], "charging");
} }
#[test] #[test]
fn waybar_json_class_fully_charged() { fn waybar_json_class_fully_charged() {
let json_str = output::format_waybar_json(100.0, &BatteryStatus::FullyCharged); let json_str = output::format_waybar_json(100.0, &BatteryStatus::FullyCharged, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "charging"); assert_eq!(parsed["class"], "charging");
} }
#[test] #[test]
fn waybar_json_class_low() { fn waybar_json_class_low() {
let json_str = output::format_waybar_json(20.0, &BatteryStatus::Low); let json_str = output::format_waybar_json(20.0, &BatteryStatus::Low, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "low"); assert_eq!(parsed["class"], "low");
} }
#[test] #[test]
fn waybar_json_class_critical_when_discharging_below_15() { fn waybar_json_class_critical_when_discharging_below_15() {
let json_str = output::format_waybar_json(10.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(10.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "critical"); assert_eq!(parsed["class"], "critical");
} }
#[test] #[test]
fn waybar_json_class_warning_when_discharging_below_30() { fn waybar_json_class_warning_when_discharging_below_30() {
let json_str = output::format_waybar_json(25.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(25.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "warning"); assert_eq!(parsed["class"], "warning");
} }
#[test] #[test]
fn waybar_json_class_normal_when_discharging_above_30() { fn waybar_json_class_normal_when_discharging_above_30() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "normal"); assert_eq!(parsed["class"], "normal");
} }
#[test] #[test]
fn waybar_json_class_at_boundary_15() { fn waybar_json_class_at_boundary_15() {
let json_str = output::format_waybar_json(15.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(15.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// 15.0 ist <= 15.0 → critical // 15.0 ist <= 15.0 → critical
assert_eq!(parsed["class"], "critical"); assert_eq!(parsed["class"], "critical");
@@ -119,15 +143,38 @@ fn waybar_json_class_at_boundary_15() {
#[test] #[test]
fn waybar_json_class_at_boundary_30() { fn waybar_json_class_at_boundary_30() {
let json_str = output::format_waybar_json(30.0, &BatteryStatus::Discharging); let json_str = output::format_waybar_json(30.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// 30.0 ist <= 30.0 → warning // 30.0 ist <= 30.0 → warning
assert_eq!(parsed["class"], "warning"); assert_eq!(parsed["class"], "warning");
} }
#[test]
fn waybar_json_class_unknown() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Unknown(0x99), None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "unknown");
}
#[test] #[test]
fn waybar_json_offline() { fn waybar_json_offline() {
let json_str = output::format_waybar_json(0.0, &BatteryStatus::Offline); let json_str = output::format_waybar_json(0.0, &BatteryStatus::Offline, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["class"], "offline"); assert_eq!(parsed["class"], "offline");
} }
#[test]
fn waybar_json_tooltip_without_sidetone() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging, None);
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let tooltip = parsed["tooltip"].as_str().unwrap();
assert!(!tooltip.contains("Sidetone"), "tooltip should not contain sidetone when None");
}
#[test]
fn waybar_json_tooltip_with_sidetone() {
let json_str = output::format_waybar_json(50.0, &BatteryStatus::Discharging, Some(10));
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let tooltip = parsed["tooltip"].as_str().unwrap();
assert!(tooltip.contains("Sidetone: 10/23"), "tooltip should contain sidetone value");
}