feat: initiale Implementierung von corsairctl
Rust CLI-Tool für Corsair Bragi-Geräte (HS80 RGB Wireless, etc.). Implementiert Protokoll-Kern, HID-Kommunikation, BragiDevice mit RAII-Lifecycle, CLI-Subcommands (battery, sidetone, led, info, json, udev), ALSA-Sidetone-Steuerung und Waybar-JSON-Output. 24 Unit-Tests für Packet-Bau, Response-Parsing und Property-Enums.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// ABOUTME: BragiDevice — RAII-Wrapper für den gesamten Geräte-Lebenszyklus.
|
||||
// ABOUTME: Kapselt Init-Handshake, Property-Abfragen und automatisches Cleanup via Drop.
|
||||
|
||||
use hidapi::{HidApi, HidDevice};
|
||||
|
||||
use crate::bragi::properties::{self, BatteryStatus, Property};
|
||||
use crate::bragi::protocol::{self, BragiResponse, ENDPOINT_HEADSET, ENDPOINT_RECEIVER};
|
||||
use crate::error::{CorsairError, Result};
|
||||
use crate::hid;
|
||||
|
||||
/// RAII-Wrapper für ein Corsair Bragi-Gerät.
|
||||
///
|
||||
/// Führt beim Erstellen die vollständige Initialisierungssequenz durch
|
||||
/// und setzt beim Drop automatisch in den Hardware-Modus zurück.
|
||||
pub struct BragiDevice {
|
||||
device: HidDevice,
|
||||
}
|
||||
|
||||
impl BragiDevice {
|
||||
/// Findet ein Corsair-Gerät, öffnet es und führt die Init-Sequenz durch.
|
||||
pub fn open() -> Result<Self> {
|
||||
let api = HidApi::new()?;
|
||||
let device = hid::find_and_open(&api)?;
|
||||
let bragi = Self { device };
|
||||
bragi.initialize()?;
|
||||
Ok(bragi)
|
||||
}
|
||||
|
||||
/// Vollständige Bragi-Handshake-Sequenz.
|
||||
fn initialize(&self) -> Result<()> {
|
||||
// Phase 1: Receiver initialisieren
|
||||
// Wake-Up: Firmware-Version lesen
|
||||
let packet = protocol::build_get_packet(ENDPOINT_RECEIVER, Property::AppFirmware.id());
|
||||
hid::send_recv_optional(&self.device, &packet)?;
|
||||
|
||||
// Receiver in Software-Modus
|
||||
let packet = protocol::build_set_packet(
|
||||
ENDPOINT_RECEIVER,
|
||||
Property::Mode.id(),
|
||||
&[0x00, protocol::MODE_SOFTWARE],
|
||||
);
|
||||
hid::send_recv_optional(&self.device, &packet)?;
|
||||
|
||||
// Heartbeat: PID abfragen
|
||||
let packet = protocol::build_get_packet(ENDPOINT_RECEIVER, Property::ProductId.id());
|
||||
hid::send_recv_optional(&self.device, &packet)?;
|
||||
|
||||
// Phase 2: Headset initialisieren
|
||||
// Headset in Software-Modus
|
||||
let packet = protocol::build_set_packet(
|
||||
ENDPOINT_HEADSET,
|
||||
Property::Mode.id(),
|
||||
&[0x00, protocol::MODE_SOFTWARE],
|
||||
);
|
||||
hid::send_recv_optional(&self.device, &packet)?;
|
||||
|
||||
// Puffer leeren
|
||||
hid::flush(&self.device)?;
|
||||
|
||||
// Heartbeat: PID abfragen — keine Antwort = Headset offline
|
||||
let packet = protocol::build_get_packet(ENDPOINT_HEADSET, Property::ProductId.id());
|
||||
let response = hid::send_recv_optional(&self.device, &packet)?;
|
||||
if response.is_none() {
|
||||
return Err(CorsairError::HeadsetOffline);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt Receiver und Headset zurück in den Hardware-Modus.
|
||||
fn cleanup(&self) {
|
||||
// Fehler beim Cleanup ignorieren — wir versuchen unser Bestes
|
||||
let packet = protocol::build_set_packet(
|
||||
ENDPOINT_HEADSET,
|
||||
Property::Mode.id(),
|
||||
&[0x00, protocol::MODE_HARDWARE],
|
||||
);
|
||||
let _ = hid::send_recv_optional(&self.device, &packet);
|
||||
|
||||
let packet = protocol::build_set_packet(
|
||||
ENDPOINT_RECEIVER,
|
||||
Property::Mode.id(),
|
||||
&[0x00, protocol::MODE_HARDWARE],
|
||||
);
|
||||
let _ = hid::send_recv_optional(&self.device, &packet);
|
||||
}
|
||||
|
||||
/// Liest eine Property vom Headset und parst die Antwort.
|
||||
fn get_headset_property(&self, property: Property) -> Result<BragiResponse> {
|
||||
let packet = protocol::build_get_packet(ENDPOINT_HEADSET, property.id());
|
||||
let raw = hid::send_recv(&self.device, &packet)?;
|
||||
protocol::parse_response(&raw)
|
||||
}
|
||||
|
||||
/// Setzt eine Property auf dem Headset.
|
||||
fn set_headset_property(&self, property: Property, data: &[u8]) -> Result<BragiResponse> {
|
||||
let packet = protocol::build_set_packet(ENDPOINT_HEADSET, property.id(), data);
|
||||
let raw = hid::send_recv(&self.device, &packet)?;
|
||||
protocol::parse_response(&raw)
|
||||
}
|
||||
|
||||
/// Batterie-Level in Prozent (0.0 - 100.0).
|
||||
pub fn battery_level(&self) -> Result<f32> {
|
||||
let resp = self.get_headset_property(Property::BatteryLevel)?;
|
||||
let promille = resp.value_u16()?;
|
||||
Ok(properties::battery_promille_to_percent(promille))
|
||||
}
|
||||
|
||||
/// Batterie-Ladezustand.
|
||||
pub fn battery_status(&self) -> Result<BatteryStatus> {
|
||||
let resp = self.get_headset_property(Property::BatteryStatus)?;
|
||||
let raw = resp.value_u8()?;
|
||||
Ok(BatteryStatus::from_byte(raw))
|
||||
}
|
||||
|
||||
/// LED-Helligkeit lesen (0-1000).
|
||||
pub fn brightness(&self) -> Result<u16> {
|
||||
let resp = self.get_headset_property(Property::Brightness)?;
|
||||
resp.value_u16()
|
||||
}
|
||||
|
||||
/// LED-Helligkeit setzen (0-1000).
|
||||
pub fn set_brightness(&self, value: u16) -> Result<()> {
|
||||
let bytes = value.to_le_bytes();
|
||||
self.set_headset_property(Property::Brightness, &[0x00, bytes[0], bytes[1]])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vendor ID des Headsets.
|
||||
pub fn vendor_id(&self) -> Result<u16> {
|
||||
let resp = self.get_headset_property(Property::VendorId)?;
|
||||
resp.value_u16()
|
||||
}
|
||||
|
||||
/// Product ID des Headsets.
|
||||
pub fn product_id(&self) -> Result<u16> {
|
||||
let resp = self.get_headset_property(Property::ProductId)?;
|
||||
resp.value_u16()
|
||||
}
|
||||
|
||||
/// Applikations-Firmware-Version.
|
||||
pub fn firmware_app(&self) -> Result<u16> {
|
||||
let resp = self.get_headset_property(Property::AppFirmware)?;
|
||||
resp.value_u16()
|
||||
}
|
||||
|
||||
/// Build-Firmware-Version.
|
||||
pub fn firmware_build(&self) -> Result<u16> {
|
||||
let resp = self.get_headset_property(Property::BuildFirmware)?;
|
||||
resp.value_u16()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BragiDevice {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// ABOUTME: Bragi-Protokoll Modul — Re-Exports für Protokoll, Properties und Device.
|
||||
// ABOUTME: Zentraler Einstiegspunkt für alle Bragi-bezogenen Typen und Funktionen.
|
||||
|
||||
pub mod device;
|
||||
pub mod properties;
|
||||
pub mod protocol;
|
||||
|
||||
pub use device::BragiDevice;
|
||||
pub use properties::{BatteryStatus, Property};
|
||||
pub use protocol::{
|
||||
BragiResponse, CORSAIR_VID, ENDPOINT_HEADSET, ENDPOINT_RECEIVER, HID_INTERFACE,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// ABOUTME: Bragi Property-IDs und Battery-Status Definitionen.
|
||||
// ABOUTME: Enthält Enums und Konvertierungen für alle bekannten Properties.
|
||||
|
||||
/// Bragi Property-IDs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Property {
|
||||
PollingRate = 0x01,
|
||||
Brightness = 0x02,
|
||||
Mode = 0x03,
|
||||
Sidetone = 0x09,
|
||||
BatteryLevel = 0x0F,
|
||||
BatteryStatus = 0x10,
|
||||
VendorId = 0x11,
|
||||
ProductId = 0x12,
|
||||
AppFirmware = 0x13,
|
||||
BuildFirmware = 0x14,
|
||||
RadioAppFirmware = 0x15,
|
||||
RadioBuildFirmware = 0x16,
|
||||
}
|
||||
|
||||
impl Property {
|
||||
pub fn id(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Batterie-Ladezustand.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BatteryStatus {
|
||||
Offline,
|
||||
Discharging,
|
||||
Low,
|
||||
Charging,
|
||||
FullyCharged,
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
impl BatteryStatus {
|
||||
pub fn from_byte(value: u8) -> Self {
|
||||
match value {
|
||||
0x00 => Self::Offline,
|
||||
0x01 => Self::Discharging,
|
||||
0x02 => Self::Low,
|
||||
0x03 | 0x04 => Self::Charging,
|
||||
0x05 => Self::FullyCharged,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Offline => "Offline",
|
||||
Self::Discharging => "Discharging",
|
||||
Self::Low => "Low",
|
||||
Self::Charging => "Charging",
|
||||
Self::FullyCharged => "Full",
|
||||
Self::Unknown(_) => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Waybar-Icon passend zum Status.
|
||||
pub fn icon(&self) -> &str {
|
||||
match self {
|
||||
Self::Offline => "",
|
||||
Self::Discharging | Self::Low => "",
|
||||
Self::Charging => "",
|
||||
Self::FullyCharged => "",
|
||||
Self::Unknown(_) => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob das Gerät gerade geladen wird.
|
||||
pub fn is_charging(&self) -> bool {
|
||||
matches!(self, Self::Charging)
|
||||
}
|
||||
}
|
||||
|
||||
/// Batterie-Level in Promille (0-1000) zu Prozent konvertieren.
|
||||
pub fn battery_promille_to_percent(promille: u16) -> f32 {
|
||||
promille as f32 / 10.0
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// ABOUTME: Bragi-Protokoll Packet-Bau und Response-Parsing (pure Logik).
|
||||
// ABOUTME: Keine I/O-Abhängigkeiten — vollständig unit-testbar.
|
||||
|
||||
use crate::error::{CorsairError, Result};
|
||||
|
||||
// Protokoll-Marker, der jedem HID-Report vorangestellt wird
|
||||
pub const PROTOCOL_MARKER: u8 = 0x02;
|
||||
|
||||
// HID Report-Größe (ohne Report-ID)
|
||||
pub const REPORT_SIZE: usize = 64;
|
||||
|
||||
// Packet-Größe inkl. Report-ID (0x00)
|
||||
pub const PACKET_SIZE: usize = 65;
|
||||
|
||||
// Endpoints
|
||||
pub const ENDPOINT_RECEIVER: u8 = 0x08;
|
||||
pub const ENDPOINT_HEADSET: u8 = 0x09;
|
||||
|
||||
// Commands
|
||||
pub const CMD_SET: u8 = 0x01;
|
||||
pub const CMD_GET: u8 = 0x02;
|
||||
|
||||
// Mode-Werte
|
||||
pub const MODE_HARDWARE: u8 = 0x01;
|
||||
pub const MODE_SOFTWARE: u8 = 0x02;
|
||||
|
||||
// USB-Identifikation
|
||||
pub const CORSAIR_VID: u16 = 0x1B1C;
|
||||
pub const HID_INTERFACE: i32 = 3;
|
||||
|
||||
// Response-Status
|
||||
pub const STATUS_OK: u8 = 0x00;
|
||||
pub const STATUS_ERROR_F0: u8 = 0xF0;
|
||||
pub const STATUS_ERROR_F1: u8 = 0xF1;
|
||||
|
||||
/// Baut ein Bragi-Paket (65 Bytes) für den HID-Versand.
|
||||
///
|
||||
/// Format: [0x00, 0x02, endpoint, command, property, ...data, 0x00-padding]
|
||||
pub fn build_packet(endpoint: u8, command: u8, property: u8, data: &[u8]) -> [u8; PACKET_SIZE] {
|
||||
let mut packet = [0u8; PACKET_SIZE];
|
||||
// Byte 0: HID Report ID
|
||||
packet[0] = 0x00;
|
||||
// Byte 1: Protokoll-Marker
|
||||
packet[1] = PROTOCOL_MARKER;
|
||||
// Byte 2: Endpoint
|
||||
packet[2] = endpoint;
|
||||
// Byte 3: Command
|
||||
packet[3] = command;
|
||||
// Byte 4: Property
|
||||
packet[4] = property;
|
||||
// Byte 5+: Optionale Daten
|
||||
let copy_len = data.len().min(PACKET_SIZE - 5);
|
||||
packet[5..5 + copy_len].copy_from_slice(&data[..copy_len]);
|
||||
packet
|
||||
}
|
||||
|
||||
/// Baut ein GET-Paket für eine Property.
|
||||
pub fn build_get_packet(endpoint: u8, property: u8) -> [u8; PACKET_SIZE] {
|
||||
build_packet(endpoint, CMD_GET, property, &[])
|
||||
}
|
||||
|
||||
/// Baut ein SET-Paket für eine Property mit Daten.
|
||||
pub fn build_set_packet(endpoint: u8, property: u8, data: &[u8]) -> [u8; PACKET_SIZE] {
|
||||
build_packet(endpoint, CMD_SET, property, data)
|
||||
}
|
||||
|
||||
/// Geparste Bragi-Antwort.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BragiResponse {
|
||||
pub status: u8,
|
||||
pub endpoint: u8,
|
||||
pub command: u8,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BragiResponse {
|
||||
/// Extrahiert einen uint16 Little-Endian Wert aus den Antwort-Daten.
|
||||
pub fn value_u16(&self) -> Result<u16> {
|
||||
if self.data.len() < 2 {
|
||||
return Err(CorsairError::ResponseTooShort {
|
||||
expected: 2,
|
||||
got: self.data.len(),
|
||||
});
|
||||
}
|
||||
Ok(u16::from_le_bytes([self.data[0], self.data[1]]))
|
||||
}
|
||||
|
||||
/// Extrahiert einen uint8 Wert aus den Antwort-Daten.
|
||||
pub fn value_u8(&self) -> Result<u8> {
|
||||
if self.data.is_empty() {
|
||||
return Err(CorsairError::ResponseTooShort {
|
||||
expected: 1,
|
||||
got: 0,
|
||||
});
|
||||
}
|
||||
Ok(self.data[0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Parst eine rohe HID-Response (64 Bytes ohne Report-ID) in eine BragiResponse.
|
||||
///
|
||||
/// Response-Format: [marker, status, endpoint, command, ...data]
|
||||
pub fn parse_response(raw: &[u8]) -> Result<BragiResponse> {
|
||||
if raw.len() < 4 {
|
||||
return Err(CorsairError::ResponseTooShort {
|
||||
expected: 4,
|
||||
got: raw.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let status = raw[1];
|
||||
let endpoint = raw[2];
|
||||
let command = raw[3];
|
||||
|
||||
// Fehler-Status prüfen
|
||||
if status == STATUS_ERROR_F0 || status == STATUS_ERROR_F1 {
|
||||
// Property-ID steht im Command-Byte bei Fehlern — aber wir haben sie
|
||||
// nicht immer. Wir melden den Fehler generisch.
|
||||
return Err(CorsairError::PropertyNotSupported { property: raw[3] });
|
||||
}
|
||||
|
||||
let data = if raw.len() > 4 {
|
||||
raw[4..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(BragiResponse {
|
||||
status,
|
||||
endpoint,
|
||||
command,
|
||||
data,
|
||||
})
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// ABOUTME: CLI-Definition mit clap derive Subcommands.
|
||||
// ABOUTME: Definiert alle verfügbaren Befehle für corsairctl.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "corsairctl", version, about = "CLI für Corsair Bragi-Geräte")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Batterie-Level und Ladezustand anzeigen
|
||||
Battery,
|
||||
|
||||
/// Sidetone-Level lesen oder setzen (0-23, ALSA-Mixer)
|
||||
Sidetone {
|
||||
/// Sidetone-Level (0-23). Ohne Angabe wird der aktuelle Wert gelesen.
|
||||
level: Option<i64>,
|
||||
},
|
||||
|
||||
/// LED-Helligkeit lesen oder setzen (0-1000)
|
||||
Led {
|
||||
/// Helligkeit (0-1000). Ohne Angabe wird der aktuelle Wert gelesen.
|
||||
brightness: Option<u16>,
|
||||
},
|
||||
|
||||
/// Geräteinformationen anzeigen (VID, PID, Firmware)
|
||||
Info,
|
||||
|
||||
/// Waybar-kompatibles JSON ausgeben
|
||||
Json,
|
||||
|
||||
/// udev-Regeln für rootless-Zugriff auf stdout ausgeben
|
||||
Udev,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// ABOUTME: Zentrales Error-Handling für corsairctl.
|
||||
// ABOUTME: Definiert CorsairError als thiserror-Enum für alle Fehlerfälle.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CorsairError {
|
||||
#[error("Kein Corsair-Gerät gefunden (VID 0x1B1C, Interface 3)")]
|
||||
DeviceNotFound,
|
||||
|
||||
#[error("Headset antwortet nicht — möglicherweise ausgeschaltet")]
|
||||
HeadsetOffline,
|
||||
|
||||
#[error("Gerät-Fehler: Property 0x{property:02X} nicht unterstützt")]
|
||||
PropertyNotSupported { property: u8 },
|
||||
|
||||
#[error("Ungültige Antwort: erwartet mindestens {expected} Bytes, bekommen {got}")]
|
||||
ResponseTooShort { expected: usize, got: usize },
|
||||
|
||||
#[error("HID-Fehler: {0}")]
|
||||
Hid(#[from] hidapi::HidError),
|
||||
|
||||
#[error("ALSA-Fehler: {0}")]
|
||||
Alsa(#[from] alsa::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CorsairError>;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// ABOUTME: Dünner Wrapper um hidapi für Corsair-Geräte.
|
||||
// ABOUTME: Kapselt Device-Discovery, Send/Receive und Buffer-Flushing.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use hidapi::{HidApi, HidDevice};
|
||||
|
||||
use crate::bragi::protocol::{CORSAIR_VID, HID_INTERFACE, PACKET_SIZE, REPORT_SIZE};
|
||||
use crate::error::{CorsairError, Result};
|
||||
|
||||
/// Findet ein Corsair-Gerät auf Interface 3 und öffnet es.
|
||||
pub fn find_and_open(api: &HidApi) -> Result<HidDevice> {
|
||||
let device_info = api
|
||||
.device_list()
|
||||
.find(|d| d.vendor_id() == CORSAIR_VID && d.interface_number() == HID_INTERFACE)
|
||||
.ok_or(CorsairError::DeviceNotFound)?;
|
||||
|
||||
let device = device_info.open_device(api)?;
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// Sendet ein Paket und liest die Antwort.
|
||||
pub fn send_recv(device: &HidDevice, packet: &[u8; PACKET_SIZE]) -> Result<Vec<u8>> {
|
||||
device.write(packet)?;
|
||||
|
||||
// 50ms warten wie in der Python-Referenz
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut buf = [0u8; REPORT_SIZE];
|
||||
let bytes_read = device.read_timeout(&mut buf, 500)?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
return Err(CorsairError::HeadsetOffline);
|
||||
}
|
||||
|
||||
Ok(buf[..bytes_read].to_vec())
|
||||
}
|
||||
|
||||
/// Sendet ein Paket und ignoriert die Antwort (für Init-Sequenz).
|
||||
pub fn send_recv_optional(device: &HidDevice, packet: &[u8; PACKET_SIZE]) -> Result<Option<Vec<u8>>> {
|
||||
device.write(packet)?;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let mut buf = [0u8; REPORT_SIZE];
|
||||
let bytes_read = device.read_timeout(&mut buf, 500)?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(buf[..bytes_read].to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Leert den HID-Empfangspuffer (nonblocking reads bis leer).
|
||||
pub fn flush(device: &HidDevice) -> Result<()> {
|
||||
let mut buf = [0u8; REPORT_SIZE];
|
||||
loop {
|
||||
// read_timeout mit 0ms = nonblocking
|
||||
let bytes_read = device.read_timeout(&mut buf, 0)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: Library-Crate für corsairctl — ermöglicht Zugriff aus Integration-Tests.
|
||||
// ABOUTME: Re-exportiert alle Module für externe Nutzung.
|
||||
|
||||
pub mod bragi;
|
||||
pub mod error;
|
||||
pub mod hid;
|
||||
pub mod output;
|
||||
pub mod sidetone;
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// ABOUTME: Einstiegspunkt für corsairctl — CLI-Dispatch zu Subcommands.
|
||||
// ABOUTME: Parst CLI-Argumente und delegiert an die jeweilige Funktionalität.
|
||||
|
||||
mod bragi;
|
||||
mod cli;
|
||||
mod error;
|
||||
mod hid;
|
||||
mod output;
|
||||
mod sidetone;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use cli::{Cli, Command};
|
||||
|
||||
fn run() -> error::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Battery => {
|
||||
let device = bragi::BragiDevice::open()?;
|
||||
let level = device.battery_level()?;
|
||||
let status = device.battery_status()?;
|
||||
println!("{}", output::format_battery(level, &status));
|
||||
}
|
||||
|
||||
Command::Sidetone { level } => {
|
||||
if let Some(value) = level {
|
||||
let clamped = value.clamp(0, 23);
|
||||
sidetone::set_level(clamped)?;
|
||||
println!("{}", output::format_sidetone(clamped));
|
||||
} else {
|
||||
let current = sidetone::get_level()?;
|
||||
println!("{}", output::format_sidetone(current));
|
||||
}
|
||||
}
|
||||
|
||||
Command::Led { brightness } => {
|
||||
let device = bragi::BragiDevice::open()?;
|
||||
if let Some(value) = brightness {
|
||||
let clamped = value.clamp(0, 1000);
|
||||
device.set_brightness(clamped)?;
|
||||
println!("{}", output::format_brightness(clamped));
|
||||
} else {
|
||||
let current = device.brightness()?;
|
||||
println!("{}", output::format_brightness(current));
|
||||
}
|
||||
}
|
||||
|
||||
Command::Info => {
|
||||
let device = bragi::BragiDevice::open()?;
|
||||
let vid = device.vendor_id()?;
|
||||
let pid = device.product_id()?;
|
||||
let fw_app = device.firmware_app()?;
|
||||
let fw_build = device.firmware_build()?;
|
||||
println!("{}", output::format_info(vid, pid, fw_app, fw_build));
|
||||
}
|
||||
|
||||
Command::Json => {
|
||||
let device = bragi::BragiDevice::open()?;
|
||||
let level = device.battery_level()?;
|
||||
let status = device.battery_status()?;
|
||||
println!("{}", output::format_waybar_json(level, &status));
|
||||
}
|
||||
|
||||
Command::Udev => {
|
||||
print!("{}", include_str!("../udev/99-corsair.rules"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("Fehler: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// ABOUTME: Formatierung für Plain-Text und Waybar-JSON Output.
|
||||
// ABOUTME: Konvertiert Gerätedaten in lesbaren Text oder Waybar-kompatibles JSON.
|
||||
|
||||
use crate::bragi::properties::BatteryStatus;
|
||||
|
||||
/// Formatiert Batterie-Informationen als Plain-Text.
|
||||
pub fn format_battery(level: f32, status: &BatteryStatus) -> String {
|
||||
format!("Battery: {level:.0}% ({status})", status = status.label())
|
||||
}
|
||||
|
||||
/// Formatiert LED-Helligkeit als Plain-Text.
|
||||
pub fn format_brightness(value: u16) -> String {
|
||||
let percent = value as f32 / 10.0;
|
||||
format!("LED Brightness: {value}/1000 ({percent:.0}%)")
|
||||
}
|
||||
|
||||
/// Formatiert Geräteinformationen als Plain-Text.
|
||||
pub fn format_info(vid: u16, pid: u16, fw_app: u16, fw_build: u16) -> String {
|
||||
format!(
|
||||
"Vendor ID: 0x{vid:04X}\n\
|
||||
Product ID: 0x{pid:04X}\n\
|
||||
Firmware: {fw_app}.{fw_build}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Formatiert Waybar-kompatibles JSON.
|
||||
///
|
||||
/// Waybar erwartet: {"text": "...", "tooltip": "...", "class": "...", "percentage": N}
|
||||
pub fn format_waybar_json(level: f32, status: &BatteryStatus) -> String {
|
||||
let icon = status.icon();
|
||||
let label = status.label();
|
||||
let percentage = level.round() as u32;
|
||||
|
||||
let class = match status {
|
||||
BatteryStatus::Charging | BatteryStatus::FullyCharged => "charging",
|
||||
BatteryStatus::Low => "low",
|
||||
BatteryStatus::Discharging if level <= 15.0 => "critical",
|
||||
BatteryStatus::Discharging if level <= 30.0 => "warning",
|
||||
BatteryStatus::Discharging => "normal",
|
||||
BatteryStatus::Offline => "offline",
|
||||
BatteryStatus::Unknown(_) => "unknown",
|
||||
};
|
||||
|
||||
let json = serde_json::json!({
|
||||
"text": format!("{icon} {percentage}%"),
|
||||
"tooltip": format!("HS80: {percentage}% — {label}"),
|
||||
"class": class,
|
||||
"percentage": percentage,
|
||||
});
|
||||
|
||||
serde_json::to_string(&json).expect("JSON-Serialisierung sollte nicht fehlschlagen")
|
||||
}
|
||||
|
||||
/// Formatiert Sidetone-Level als Plain-Text.
|
||||
pub fn format_sidetone(level: i64) -> String {
|
||||
format!("Sidetone: {level}/23")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// ABOUTME: ALSA-Mixer Integration für Sidetone-Steuerung.
|
||||
// ABOUTME: Liest und setzt den Sidetone-Wert über den ALSA-Mixer der Corsair-Soundkarte.
|
||||
|
||||
use alsa::mixer::{Mixer, SelemChannelId, SelemId};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
const CARD_NAME: &str = "Gaming";
|
||||
const SIDETONE_VOLUME: &str = "Sidetone Playback Volume";
|
||||
const SIDETONE_SWITCH: &str = "Sidetone Playback Switch";
|
||||
|
||||
/// Findet die ALSA-Card-Nummer für die Corsair Gaming Soundkarte.
|
||||
fn find_card() -> Result<String> {
|
||||
// ALSA-Cards durchiterieren
|
||||
for card_idx in 0..32 {
|
||||
let name = format!("hw:{card_idx}");
|
||||
if let Ok(ctl) = alsa::Ctl::new(&name, false) {
|
||||
if let Ok(info) = ctl.card_info() {
|
||||
let card_name = info.get_name().unwrap_or_default();
|
||||
if card_name.contains(CARD_NAME) {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(crate::error::CorsairError::DeviceNotFound)
|
||||
}
|
||||
|
||||
/// Liest den aktuellen Sidetone-Level (0-23).
|
||||
pub fn get_level() -> Result<i64> {
|
||||
let card = find_card()?;
|
||||
let mixer = Mixer::new(&card, false)?;
|
||||
|
||||
let selem_id = SelemId::new(SIDETONE_VOLUME, 0);
|
||||
let selem = mixer
|
||||
.find_selem(&selem_id)
|
||||
.ok_or(crate::error::CorsairError::DeviceNotFound)?;
|
||||
|
||||
let value = selem.get_playback_volume(SelemChannelId::FrontLeft)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Setzt den Sidetone-Level (0-23).
|
||||
pub fn set_level(level: i64) -> Result<()> {
|
||||
let card = find_card()?;
|
||||
let mixer = Mixer::new(&card, false)?;
|
||||
|
||||
// Volume setzen
|
||||
let volume_id = SelemId::new(SIDETONE_VOLUME, 0);
|
||||
let volume_elem = mixer
|
||||
.find_selem(&volume_id)
|
||||
.ok_or(crate::error::CorsairError::DeviceNotFound)?;
|
||||
|
||||
volume_elem.set_playback_volume_all(level)?;
|
||||
|
||||
// Switch aktivieren falls Level > 0
|
||||
let switch_id = SelemId::new(SIDETONE_SWITCH, 0);
|
||||
if let Some(switch_elem) = mixer.find_selem(&switch_id) {
|
||||
let enabled = if level > 0 { 1 } else { 0 };
|
||||
let _ = switch_elem.set_playback_switch_all(enabled);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user