use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
const AZERON_VENDOR_ID: u16 = 0x16d0;
const AZERON_CYBORG2_PRODUCT_ID: u16 = 0x12f7;
const KEYBOARD_USAGE_PAGE: u16 = 0x0001;
const LED_CONTROL_USAGE_PAGE: u16 = 0xff01;
const AZERON_HID_PACKET_SIZE: usize = 64;
const AZERON_PACKET_TYPE_LED: u16 = 0x0001;
const AZERON_PACKET_TYPE_KEEPALIVE: u16 = 0x0000;
const AZERON_BRIGHTNESS_BASE: u16 = 256;
const AZERON_BRIGHTNESS_MAX_STEP: u16 = 150;
const AZERON_LED_BYTE_4: u8 = 0x01;
const AZERON_LED_BYTE_5: u8 = 0x01;
const AZERON_LED_BYTE_8: u8 = 0x00;
const AZERON_LED_BYTE_9: u8 = 0x00;
const AZERON_LED_REPORT_ID: u8 = 0x00;
const AZERON_CMD_SET_COLOR: u8 = 0x01;
const AZERON_CMD_SET_BRIGHTNESS: u8 = 0x02;
const AZERON_CMD_SET_PATTERN: u8 = 0x03;
const AZERON_ZONE_GLOBAL: u8 = 0xFF;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum LedZone {
Side,
All,
Global,
Logo,
Keys,
Thumbstick,
#[serde(untagged)]
Unknown(u8),
}
impl LedZone {
pub const fn to_raw_id(self) -> u8 {
match self {
Self::Logo => 0x00,
Self::Keys => 0x01,
Self::Thumbstick => 0x02,
Self::Side => 0x00, Self::All => 0x03,
Self::Global => 0xFF,
Self::Unknown(id) => id,
}
}
pub const fn to_physical_zone(self) -> Self {
match self {
Self::Logo | Self::Keys | Self::Thumbstick => Self::Side,
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum LedPattern {
Static,
Breathing,
Rainbow,
RainbowWave,
}
impl Default for LedPattern {
fn default() -> Self {
LedPattern::Static
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeviceLedState {
pub zone_colors: std::collections::HashMap<LedZone, (u8, u8, u8)>,
pub zone_brightness: std::collections::HashMap<LedZone, u8>,
pub global_brightness: u8,
pub active_pattern: LedPattern,
pub layer_colors: std::collections::HashMap<usize, (u8, u8, u8)>,
}
impl Default for DeviceLedState {
fn default() -> Self {
let mut zone_colors = std::collections::HashMap::new();
let mut zone_brightness = std::collections::HashMap::new();
zone_colors.insert(LedZone::Side, (50, 100, 255));
zone_colors.insert(LedZone::Logo, (50, 100, 255));
zone_colors.insert(LedZone::Keys, (50, 100, 255));
zone_colors.insert(LedZone::Thumbstick, (50, 100, 255));
zone_brightness.insert(LedZone::Side, 100);
zone_brightness.insert(LedZone::Logo, 100);
zone_brightness.insert(LedZone::Keys, 100);
zone_brightness.insert(LedZone::Thumbstick, 100);
Self {
zone_colors,
zone_brightness,
global_brightness: 100,
active_pattern: LedPattern::Static,
layer_colors: std::collections::HashMap::new(),
}
}
}
#[derive(Debug)]
pub struct LedState {
pub zone_colors: HashMap<LedZone, (u8, u8, u8)>,
pub zone_brightness: HashMap<LedZone, u8>,
pub global_brightness: u8,
pub active_pattern: LedPattern,
pub animation_handle: Option<tokio::task::JoinHandle<()>>,
pub layer_colors: HashMap<usize, (u8, u8, u8)>,
}
impl LedState {
pub fn clone_except_handle(&self) -> LedState {
LedState {
zone_colors: self.zone_colors.clone(),
zone_brightness: self.zone_brightness.clone(),
global_brightness: self.global_brightness,
active_pattern: self.active_pattern,
animation_handle: None,
layer_colors: self.layer_colors.clone(),
}
}
}
impl Default for LedState {
fn default() -> Self {
let mut zone_colors = HashMap::new();
let mut zone_brightness = HashMap::new();
zone_colors.insert(LedZone::Logo, (50, 100, 255));
zone_colors.insert(LedZone::Keys, (50, 100, 255));
zone_colors.insert(LedZone::Thumbstick, (50, 100, 255));
zone_colors.insert(LedZone::Side, (50, 100, 255));
zone_brightness.insert(LedZone::Logo, 100);
zone_brightness.insert(LedZone::Keys, 100);
zone_brightness.insert(LedZone::Thumbstick, 100);
zone_brightness.insert(LedZone::Side, 100);
let layer_colors = HashMap::new();
Self {
zone_colors,
zone_brightness,
global_brightness: 100,
active_pattern: LedPattern::Static,
animation_handle: None,
layer_colors,
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum LedError {
#[error("HID API error: {0}")]
HidApi(#[from] hidapi::HidError),
#[error("Azeron LED control interface not found")]
DeviceNotFound,
#[error("HID write failed")]
HidWriteFailed,
#[error("Task join failed")]
TaskJoinFailed,
#[error("Invalid value: {0}")]
InvalidValue(String),
}
pub struct LedController {
device: hidapi::HidDevice,
state: Arc<RwLock<LedState>>,
}
unsafe impl Send for LedController {}
unsafe impl Sync for LedController {}
impl LedController {
pub fn find_led_interface() -> Result<Self, LedError> {
info!("Searching for Azeron LED control interface...");
let api = hidapi::HidApi::new()
.map_err(LedError::HidApi)?;
let mut found_interfaces = Vec::new();
for device_info in api.device_list() {
if device_info.vendor_id() != AZERON_VENDOR_ID {
continue;
}
let product_id = device_info.product_id();
if product_id != AZERON_CYBORG2_PRODUCT_ID {
debug!("Skipping non-Cyborg2 Azeron device (PID={:04x})", product_id);
continue;
}
let usage_page = device_info.usage_page();
let interface_number = device_info.interface_number();
debug!(
"Found Azeron Cyborg 2 HID interface: VID={:04x} PID={:04x} usage_page={:04x} interface={}",
AZERON_VENDOR_ID, product_id, usage_page, interface_number
);
found_interfaces.push((usage_page, interface_number, product_id));
if usage_page == KEYBOARD_USAGE_PAGE {
debug!("Skipping keyboard interface (usage_page={:04x})", usage_page);
continue;
}
if (usage_page & 0xff00) != LED_CONTROL_USAGE_PAGE {
debug!("Skipping non-LED interface (usage_page={:04x})", usage_page);
continue;
}
info!(
"Opening Azeron Cyborg 2 LED control interface: usage_page={:04x} interface={}",
usage_page,
interface_number
);
let device = device_info.open_device(&api)
.map_err(LedError::HidApi)?;
return Ok(Self {
device,
state: Arc::new(RwLock::new(LedState::default())),
});
}
if !found_interfaces.is_empty() {
warn!(
"Found {} Azeron interface(s) but none suitable for LED control. Interfaces: {:?}",
found_interfaces.len(),
found_interfaces
);
}
Err(LedError::DeviceNotFound)
}
pub async fn set_zone_color(
&self,
zone: LedZone,
r: u8,
g: u8,
b: u8,
) -> Result<(), LedError> {
let zone_id = zone.to_raw_id();
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = AZERON_LED_REPORT_ID;
buffer[1] = AZERON_CMD_SET_COLOR;
buffer[2] = zone_id;
buffer[3] = r;
buffer[4] = g;
buffer[5] = b;
debug!(
"Sending LED color command: zone={}, RGB=({}, {}, {})",
zone_id, r, g, b
);
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
{
let mut state = self.state.write().await;
state.zone_colors.insert(zone, (r, g, b));
}
info!("LED color set: zone={}, RGB=({}, {}, {})", zone_id, r, g, b);
Ok(())
}
pub async fn set_global_brightness(
&self,
brightness: u8,
) -> Result<(), LedError> {
if brightness > 100 {
return Err(LedError::InvalidValue("Brightness must be 0-100".into()));
}
let hw_brightness = AZERON_BRIGHTNESS_BASE + (u16::from(brightness) * AZERON_BRIGHTNESS_MAX_STEP / 100);
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = (AZERON_PACKET_TYPE_LED & 0xFF) as u8;
buffer[1] = ((AZERON_PACKET_TYPE_LED >> 8) & 0xFF) as u8;
let counter: u16 = 0x09f5; buffer[2] = (counter & 0xFF) as u8;
buffer[3] = ((counter >> 8) & 0xFF) as u8;
buffer[4] = AZERON_LED_BYTE_4;
buffer[5] = AZERON_LED_BYTE_5;
buffer[6] = (hw_brightness & 0xFF) as u8;
buffer[7] = ((hw_brightness >> 8) & 0xFF) as u8;
buffer[8] = AZERON_LED_BYTE_8;
buffer[9] = AZERON_LED_BYTE_9;
debug!("Sending LED global brightness command: {}% (raw: {})", brightness, hw_brightness);
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
{
let mut state = self.state.write().await;
state.global_brightness = brightness;
}
info!("LED global brightness set: {}% (hw: {})", brightness, hw_brightness);
Ok(())
}
pub async fn set_zone_brightness(
&self,
zone: LedZone,
brightness: u8,
) -> Result<(), LedError> {
{
let mut state = self.state.write().await;
state.zone_brightness.insert(zone, brightness);
}
self.set_global_brightness(brightness).await
}
pub async fn send_keepalive(&self) -> Result<(), LedError> {
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = (AZERON_PACKET_TYPE_KEEPALIVE & 0xFF) as u8;
buffer[1] = ((AZERON_PACKET_TYPE_KEEPALIVE >> 8) & 0xFF) as u8;
let counter: u16 = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() & 0xFFFF) as u16;
let counter = 0x1259 + (counter & 0xFF); buffer[2] = (counter & 0xFF) as u8;
buffer[3] = ((counter >> 8) & 0xFF) as u8;
buffer[4] = AZERON_LED_BYTE_4;
buffer[5] = AZERON_LED_BYTE_5;
buffer[6] = 0x00;
buffer[7] = 0x00;
buffer[8] = 0x00;
buffer[9] = 0x00;
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
debug!("Sent LED keepalive packet (counter: {:#04x})", counter);
Ok(())
}
pub async fn get_state(&self) -> LedState {
self.state.read().await.clone_except_handle()
}
pub async fn get_zone_color(&self, zone: LedZone) -> Option<(u8, u8, u8)> {
self.state.read().await.zone_colors.get(&zone).copied()
}
pub async fn get_global_brightness(&self) -> u8 {
self.state.read().await.global_brightness
}
pub async fn get_zone_brightness(&self, zone: LedZone) -> u8 {
let state = self.state.read().await;
state.zone_brightness.get(&zone)
.copied()
.unwrap_or(state.global_brightness)
}
pub async fn get_brightness(&self) -> u8 {
self.get_global_brightness().await
}
pub async fn set_brightness(&self, brightness: u8) -> Result<(), LedError> {
self.set_global_brightness(brightness).await
}
pub async fn get_all_zone_colors(&self) -> HashMap<LedZone, (u8, u8, u8)> {
self.state.read().await.zone_colors.clone()
}
pub async fn set_layer_color(
&self,
layer_id: usize,
color: (u8, u8, u8),
) {
let mut state = self.state.write().await;
state.layer_colors.insert(layer_id, color);
debug!("Set layer {} color to RGB {:?}", layer_id, color);
}
pub async fn get_layer_color(&self, layer_id: usize) -> Option<(u8, u8, u8)> {
let state = self.state.read().await;
state.layer_colors.get(&layer_id).copied()
}
pub async fn apply_layer_color(
&self,
layer_id: usize,
zone: LedZone,
) -> Result<(), LedError> {
if let Some((r, g, b)) = self.get_layer_color(layer_id).await {
self.set_zone_color(zone, r, g, b).await?;
debug!("Applied layer {} color to zone {:?}", layer_id, zone);
} else {
debug!("No color configured for layer {}, using default", layer_id);
}
Ok(())
}
pub async fn set_pattern(
&self,
pattern: LedPattern,
) -> Result<(), LedError> {
{
let mut state = self.state.write().await;
if let Some(handle) = state.animation_handle.take() {
handle.abort();
}
state.active_pattern = pattern;
}
if self.supports_hardware_pattern(pattern).await {
self.send_hardware_pattern_command(pattern).await?;
} else {
self.start_software_animation(pattern).await?;
}
info!("LED pattern set: {:?}", pattern);
Ok(())
}
pub async fn get_pattern(&self) -> LedPattern {
self.state.read().await.active_pattern
}
async fn supports_hardware_pattern(&self, pattern: LedPattern) -> bool {
if pattern == LedPattern::Static {
return true;
}
false
}
async fn send_hardware_pattern_command(&self, pattern: LedPattern) -> Result<(), LedError> {
let pattern_id = match pattern {
LedPattern::Static => 0x00,
LedPattern::Breathing => 0x01,
LedPattern::Rainbow => 0x02,
LedPattern::RainbowWave => 0x03,
};
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = AZERON_LED_REPORT_ID;
buffer[1] = AZERON_CMD_SET_PATTERN;
buffer[2] = pattern_id;
debug!("Sending LED pattern command: pattern_id={}", pattern_id);
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
Ok(())
}
async fn start_software_animation(&self, pattern: LedPattern) -> Result<(), LedError> {
match pattern {
LedPattern::Static => {
Ok(())
}
LedPattern::Breathing => {
self.start_breathing_animation().await
}
LedPattern::Rainbow | LedPattern::RainbowWave => {
self.start_rainbow_animation(matches!(pattern, LedPattern::RainbowWave)).await
}
}
}
async fn start_breathing_animation(&self) -> Result<(), LedError> {
let state = Arc::clone(&self.state);
let handle = tokio::spawn(async move {
let mut brightness = 0i16;
let mut direction = 1i16;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(50));
loop {
interval.tick().await;
brightness += direction * 5; if brightness >= 100 {
brightness = 100;
direction = -1;
} else if brightness <= 0 {
brightness = 0;
direction = 1;
}
let zone_colors = state.read().await.zone_colors.clone();
for (_zone, (r, g, b)) in zone_colors {
let _scale = brightness as f32 / 100.0;
let _scaled_r = (r as f32 * _scale) as u8;
let _scaled_g = (g as f32 * _scale) as u8;
let _scaled_b = (b as f32 * _scale) as u8;
}
}
});
let mut state = self.state.write().await;
state.animation_handle = Some(handle);
warn!("Breathing animation started - HID writes require channel-based architecture");
Ok(())
}
async fn start_rainbow_animation(&self, _wave: bool) -> Result<(), LedError> {
let _state = Arc::clone(&self.state);
let handle = tokio::spawn(async move {
let mut hue = 0u16;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(50));
loop {
interval.tick().await;
let (_r, _g, _b) = hsv_to_rgb(hue, 100, 100);
hue = (hue + 5) % 360; }
});
let mut state = self.state.write().await;
state.animation_handle = Some(handle);
warn!("Rainbow animation started - HID writes require channel-based architecture");
Ok(())
}
async fn send_color_with_brightness(
&self,
zone: LedZone,
r: u8,
g: u8,
b: u8,
_brightness_percent: u8,
) -> Result<(), LedError> {
self.send_color_command(zone, r, g, b).await
}
async fn send_color_command(
&self,
zone: LedZone,
r: u8,
g: u8,
b: u8,
) -> Result<(), LedError> {
let zone_id = zone.to_raw_id();
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = AZERON_LED_REPORT_ID;
buffer[1] = AZERON_CMD_SET_COLOR;
buffer[2] = zone_id;
buffer[3] = r;
buffer[4] = g;
buffer[5] = b;
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
Ok(())
}
pub async fn export_state(&self) -> DeviceLedState {
let state = self.state.read().await;
DeviceLedState {
zone_colors: state.zone_colors.clone(),
zone_brightness: state.zone_brightness.clone(),
global_brightness: state.global_brightness,
active_pattern: state.active_pattern,
layer_colors: state.layer_colors.clone(),
}
}
pub async fn import_state(&self, imported: DeviceLedState) -> Result<(), LedError> {
let zone_count = imported.zone_colors.len();
let global_brightness = imported.global_brightness;
let active_pattern = imported.active_pattern;
for (zone, (r, g, b)) in &imported.zone_colors {
self.send_color_command(*zone, *r, *g, *b).await?;
}
self.send_global_brightness_command(global_brightness).await?;
if active_pattern != LedPattern::Static {
self.set_pattern(active_pattern).await?;
}
{
let mut state = self.state.write().await;
state.zone_colors = imported.zone_colors;
state.zone_brightness = imported.zone_brightness;
state.global_brightness = imported.global_brightness;
state.active_pattern = imported.active_pattern;
state.layer_colors = imported.layer_colors;
}
info!("LED state restored: {} zones, brightness {}%, pattern {:?}",
zone_count, global_brightness, active_pattern);
Ok(())
}
async fn send_global_brightness_command(&self, brightness: u8) -> Result<(), LedError> {
let mut buffer = [0u8; AZERON_HID_PACKET_SIZE];
buffer[0] = AZERON_LED_REPORT_ID;
buffer[1] = AZERON_CMD_SET_BRIGHTNESS;
buffer[2] = AZERON_ZONE_GLOBAL;
buffer[3] = brightness;
self.device.write(&buffer)
.map_err(LedError::HidApi)?;
Ok(())
}
}
fn hsv_to_rgb(h: u16, s: u8, v: u8) -> (u8, u8, u8) {
let s = s as f32 / 100.0;
let v = v as f32 / 100.0;
let c = v * s;
let x = c * (1.0 - ((h as f32 / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = if h < 60 {
(c, x, 0.0)
} else if h < 120 {
(x, c, 0.0)
} else if h < 180 {
(0.0, c, x)
} else if h < 240 {
(0.0, x, c)
} else if h < 300 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_led_zone_serialization() {
assert_eq!(LedZone::Logo.to_raw_id(), 0x00);
assert_eq!(LedZone::Keys.to_raw_id(), 0x01);
assert_eq!(LedZone::Thumbstick.to_raw_id(), 0x02);
assert_eq!(LedZone::All.to_raw_id(), 0x03);
assert_eq!(LedZone::Global.to_raw_id(), 0xFF);
}
#[test]
fn test_led_state_default() {
let state = LedState::default();
assert_eq!(state.global_brightness, 100);
assert!(state.zone_colors.contains_key(&LedZone::Logo));
assert!(state.zone_colors.contains_key(&LedZone::Keys));
assert!(state.zone_colors.contains_key(&LedZone::Thumbstick));
assert!(state.zone_brightness.contains_key(&LedZone::Logo));
assert!(state.zone_brightness.contains_key(&LedZone::Keys));
assert!(state.zone_brightness.contains_key(&LedZone::Thumbstick));
assert_eq!(state.zone_brightness[&LedZone::Logo], 100);
}
#[test]
fn test_brightness_range_validation() {
let error = LedError::InvalidValue("test".to_string());
assert_eq!(error.to_string(), "Invalid value: test");
}
#[test]
fn test_constants() {
assert_eq!(AZERON_VENDOR_ID, 0x16d0);
assert_eq!(KEYBOARD_USAGE_PAGE, 0x0001);
}
#[test]
fn test_zone_color_storage() {
let mut state = LedState::default();
state.zone_colors.insert(LedZone::Logo, (255, 128, 64));
assert_eq!(state.zone_colors.get(&LedZone::Logo), Some(&(255, 128, 64)));
assert!(state.zone_colors.contains_key(&LedZone::Keys));
assert!(state.zone_colors.contains_key(&LedZone::Thumbstick));
}
#[test]
fn test_multiple_zones_independent() {
let mut state = LedState::default();
state.zone_colors.insert(LedZone::Logo, (255, 0, 0)); state.zone_colors.insert(LedZone::Keys, (0, 255, 0)); state.zone_colors.insert(LedZone::Thumbstick, (0, 0, 255));
assert_eq!(state.zone_colors.get(&LedZone::Logo), Some(&(255, 0, 0)));
assert_eq!(state.zone_colors.get(&LedZone::Keys), Some(&(0, 255, 0)));
assert_eq!(state.zone_colors.get(&LedZone::Thumbstick), Some(&(0, 0, 255)));
}
#[test]
fn test_zone_brightness_storage() {
let mut state = LedState::default();
state.zone_brightness.insert(LedZone::Logo, 50);
assert_eq!(state.zone_brightness.get(&LedZone::Logo), Some(&50));
assert_eq!(state.zone_brightness.get(&LedZone::Keys), Some(&100));
}
#[test]
fn test_global_brightness_default() {
let state = LedState::default();
assert_eq!(state.global_brightness, 100);
}
#[test]
fn test_global_brightness_storage() {
let mut state = LedState::default();
state.global_brightness = 75;
assert_eq!(state.global_brightness, 75);
}
#[test]
fn test_led_zone_to_raw_id_extended() {
assert_eq!(LedZone::Logo.to_raw_id(), 0x00);
assert_eq!(LedZone::Keys.to_raw_id(), 0x01);
assert_eq!(LedZone::Thumbstick.to_raw_id(), 0x02);
assert_eq!(LedZone::All.to_raw_id(), 0x03);
assert_eq!(LedZone::Global.to_raw_id(), 0xFF);
assert_eq!(LedZone::Unknown(0x42).to_raw_id(), 0x42);
assert_eq!(LedZone::Unknown(0x00).to_raw_id(), 0x00);
}
#[test]
fn test_layer_colors_field_exists() {
let state = LedState::default();
assert!(state.layer_colors.is_empty()); }
#[test]
fn test_led_state_includes_layer_colors() {
let mut state = LedState::default();
state.layer_colors.insert(1, (255, 0, 0));
state.layer_colors.insert(2, (0, 255, 0));
let cloned = state.clone_except_handle();
assert_eq!(cloned.layer_colors.len(), 2);
assert_eq!(cloned.layer_colors.get(&1), Some(&(255, 0, 0)));
assert_eq!(cloned.layer_colors.get(&2), Some(&(0, 255, 0)));
assert!(cloned.animation_handle.is_none());
}
#[test]
fn test_multiple_layer_colors_independent() {
let mut state = LedState::default();
state.layer_colors.insert(0, (255, 255, 255)); state.layer_colors.insert(1, (0, 0, 255)); state.layer_colors.insert(2, (0, 255, 0));
assert_eq!(state.layer_colors.len(), 3);
assert_eq!(state.layer_colors.get(&0), Some(&(255, 255, 255)));
assert_eq!(state.layer_colors.get(&1), Some(&(0, 0, 255)));
assert_eq!(state.layer_colors.get(&2), Some(&(0, 255, 0)));
}
}