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:
2026-03-27 17:34:37 +01:00
commit c5f8625345
19 changed files with 1501 additions and 0 deletions
+158
View File
@@ -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();
}
}
+12
View File
@@ -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,
};
+82
View File
@@ -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
}
+134
View File
@@ -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
View File
@@ -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,
}
+27
View File
@@ -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
View File
@@ -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(())
}
+8
View File
@@ -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
View File
@@ -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);
}
}
+57
View File
@@ -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")
}
+64
View File
@@ -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(())
}