3 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
9 changed files with 107 additions and 25 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
+1 -1
View File
@@ -144,7 +144,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "corsairctl" name = "corsairctl"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"alsa", "alsa",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "corsairctl" name = "corsairctl"
version = "0.1.2" 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"
+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>,
}, },
+2 -3
View File
@@ -33,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));
+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());
}
+31
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);
@@ -125,6 +149,13 @@ fn waybar_json_class_at_boundary_30() {
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, None); let json_str = output::format_waybar_json(0.0, &BatteryStatus::Offline, None);