Compare commits
9 Commits
v0.1.0
..
b7e42f0911
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e42f0911 | |||
| baada36222 | |||
| e364f4edec | |||
| 8a9f31f1b4 | |||
| 0ebecccc1b | |||
| c38996b8a4 | |||
| 914ddf114e | |||
| 098b53744d | |||
| 960bc60b20 |
@@ -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
|
||||||
Generated
+5
-5
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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+: Daten — Bei 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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user