From 8a07c618927cf1ecd6f324ad56d413490d9adcda Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Tue, 8 Sep 2026 16:55:47 +0200 Subject: [PATCH 1/7] fix(asusctl): use slice::fill in anime-diag example Replace the manual element-wise loop with slice::fill to address the clippy::manual_slice_fill lint when mutating matrix rows. --- asusctl/examples/anime-diag.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/asusctl/examples/anime-diag.rs b/asusctl/examples/anime-diag.rs index 5302e61c3..2eb444bd7 100644 --- a/asusctl/examples/anime-diag.rs +++ b/asusctl/examples/anime-diag.rs @@ -24,9 +24,7 @@ fn main() { } for c in (0..35).step_by(step) { - for i in &mut matrix.get_mut()[c] { - *i = 50; - } + matrix.get_mut()[c].fill(50); } let anime_type = get_anime_type(); From 0f2208563ac46490abf488a1aca35bb4e7e70735 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Tue, 8 Sep 2026 16:56:30 +0200 Subject: [PATCH 2/7] feat(rog-platform): add dynamic lighting led sysfs abstraction Introduce a validated wrapper for Linux Dynamic Lighting LED nodes. Require generic mandatory attributes, probe optional capabilities, and validate ranges, palette capacity, exact buffers, and sysfs errors. Map missing sysfs attributes on numeric writes to AttrNotFound. Keep ASUS aura_mode separate from generic node validation. The unused optional frame sink remains deliberately unexposed. Signed-off-by: Marco Scardovi --- rog-platform/src/dynamic_led.rs | 329 ++++++++++++++++++++++++++++++++ rog-platform/src/lib.rs | 16 +- 2 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 rog-platform/src/dynamic_led.rs diff --git a/rog-platform/src/dynamic_led.rs b/rog-platform/src/dynamic_led.rs new file mode 100644 index 000000000..a60850874 --- /dev/null +++ b/rog-platform/src/dynamic_led.rs @@ -0,0 +1,329 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use log::{info, warn}; + +use crate::error::{PlatformError, Result}; +use crate::{attr_num, attr_string, to_device}; + +/// Dynamic Lighting class device under `/sys/class/leds/`. +/// +/// Wraps a kernel `led-class-dynamic` sysfs node exposing effects, palette, +/// speed, direction, power states, direct buffer streaming, and standard +/// brightness attributes. +#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)] +pub struct DynamicLed { + path: PathBuf, +} + +impl DynamicLed { + attr_string!("effect", path); + + attr_string!("effect_index", path); + + attr_string!("direction", path); + + attr_string!("direction_index", path); + + attr_string!("effects_palette", path); + + attr_string!("speed_range", path); + + attr_string!("zone_type", path); + + attr_string!("matrix_dimensions", path); + + attr_string!("power_states", path); + + attr_string!("power_states_index", path); + + attr_num!("speed", path, u32); + + attr_num!("max_palette_entries", path, u32); + + attr_num!("led_count", path, u32); + + attr_num!("brightness", path, u8); + + attr_num!("max_brightness", path, u8); + + /// Create a new `DynamicLed` by matching the exact sysfs name (e.g. + /// `"aura:keyboard"`). + pub fn new(name: &str) -> Result { + let mut enumerator = udev::Enumerator::new().map_err(|err| { + warn!("DynamicLed udev enumerator failed: {err}"); + PlatformError::Udev("enumerator failed".into(), err) + })?; + enumerator.match_subsystem("leds").map_err(|err| { + warn!("DynamicLed match_subsystem failed: {err}"); + PlatformError::Udev("match_subsystem failed".into(), err) + })?; + + for device in enumerator.scan_devices().map_err(|err| { + warn!("DynamicLed scan_devices failed: {err}"); + PlatformError::Udev("scan_devices failed".into(), err) + })? { + let sysname = device.sysname().to_string_lossy(); + if sysname == name { + let syspath = device.syspath(); + if Self::is_dynamic_node(syspath) { + info!("Found Dynamic Lighting LED device at {:?}", sysname); + return Ok(Self { + path: syspath.to_path_buf(), + }); + } + } + } + + Err(PlatformError::MissingFunction(format!( + "DynamicLed::new(): no dynamic LED named '{name}' found" + ))) + } + + /// Helper to find a dynamic LED by name. + pub fn find(name: &str) -> Result { + Self::new(name) + } + + /// Return the LED name (e.g. "aura:keyboard" or "asus::kbd_backlight"). + pub fn name(&self) -> &str { + self.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + } + + /// Check if a dynamic LED is present on the system. + pub fn is_available(name: &str) -> bool { + Self::find(name).is_ok() + } + + /// Return the sysfs path. + pub fn path(&self) -> &Path { + &self.path + } + + pub fn from_syspath(path: PathBuf) -> Result { + if !Self::is_dynamic_node(&path) { + return Err(PlatformError::MissingFunction(format!( + "{} is not a Dynamic Lighting LED node", + path.display() + ))); + } + Ok(Self { path }) + } + + fn is_dynamic_node(path: &Path) -> bool { + [ + "effect", "effect_index", "zone_type", "led_count", + ] + .iter() + .all(|attr| path.join(attr).exists()) + } + + /// Read and parse space-separated list of supported effects from + /// `effect_index`. + pub fn get_supported_effects_list(&self) -> Result> { + let raw = self.get_effect_index()?; + Ok(raw.split_whitespace().map(String::from).collect()) + } + + /// Check if a given effect mode string is supported by the kernel driver. + pub fn is_effect_supported(&self, effect: &str) -> bool { + self.supports_effect(effect).unwrap_or(false) + } + + /// Check effect support while preserving sysfs read errors. + pub fn supports_effect(&self, effect: &str) -> Result { + Ok(self + .get_supported_effects_list()? + .iter() + .any(|candidate| candidate == effect)) + } + + /// Read and parse space-separated list of supported directions from + /// `direction_index`. + pub fn get_supported_directions_list(&self) -> Result> { + let raw = self.get_direction_index()?; + Ok(raw.split_whitespace().map(String::from).collect()) + } + + /// Set speed after validating the optional advertised inclusive range. + pub fn set_supported_speed(&self, speed: u32) -> Result<()> { + if !self.has_speed() { + return Err(PlatformError::AttrNotFound("speed".into())); + } + if self.has_speed_range() { + let (min, max) = parse_speed_range(&self.get_speed_range()?)?; + if !(min..=max).contains(&speed) { + return Err(PlatformError::InvalidValue); + } + } + self.set_speed(speed) + } + + /// Set direction after validating the optional advertised values. + pub fn set_supported_direction(&self, direction: &str) -> Result<()> { + if !self.has_direction() { + return Err(PlatformError::AttrNotFound("direction".into())); + } + if self.has_direction_index() + && !self + .get_supported_directions_list()? + .iter() + .any(|candidate| candidate == direction) + { + return Err(PlatformError::InvalidValue); + } + self.set_direction(direction) + } + + /// Write one complete RGB frame to the `direct_buffer` binary attribute. + pub fn write_direct(&self, data: &[u8]) -> Result<()> { + let led_count = self.get_led_count()? as usize; + validate_direct_len(led_count, data.len())?; + + let direct_path = self.path.join("direct_buffer"); + let mut file = OpenOptions::new() + .write(true) + .open(&direct_path) + .map_err(|e| PlatformError::IoPath(direct_path.to_string_lossy().into_owned(), e))?; + file.write_all(data) + .map_err(|e| PlatformError::IoPath(direct_path.to_string_lossy().into_owned(), e)) + } + + /// Write palette colors as formatted `"#RRGGBB #RRGGBB ..."` string to + /// `effects_palette`. + pub fn set_palette_colors(&self, colors: &[(u8, u8, u8)]) -> Result<()> { + if !self.has_effects_palette() { + return Err(PlatformError::AttrNotFound("effects_palette".into())); + } + if self.has_max_palette_entries() && colors.len() > self.get_max_palette_entries()? as usize + { + return Err(PlatformError::InvalidValue); + } + let formatted: Vec = colors + .iter() + .map(|(r, g, b)| format!("#{r:02x}{g:02x}{b:02x}")) + .collect(); + let palette_str = formatted.join(" "); + self.set_effects_palette(&palette_str) + } + + /// Whether this ASUS node exposes the non-generic topology selector. + pub fn has_asus_aura_mode(&self) -> bool { + self.name().starts_with("aura:") && self.path.join("aura_mode").exists() + } + + /// Read the active ASUS topology mode. + pub fn get_asus_aura_mode(&self) -> Result { + parse_active_index( + &std::fs::read_to_string(self.path.join("aura_mode")).map_err(|e| { + PlatformError::IoPath(self.path.join("aura_mode").display().to_string(), e) + })?, + ) + } + + /// Set the ASUS topology mode (`auto`, `unified`, or `split`). + pub fn set_asus_aura_mode(&self, mode: &str) -> Result<()> { + if !matches!(mode, "auto" | "unified" | "split") { + return Err(PlatformError::InvalidValue); + } + let path = self.path.join("aura_mode"); + std::fs::write(&path, mode) + .map_err(|e| PlatformError::IoPath(path.display().to_string(), e)) + } +} + +fn validate_direct_len(led_count: usize, data_len: usize) -> Result<()> { + let expected = led_count + .checked_mul(3) + .ok_or(PlatformError::InvalidValue)?; + if data_len == expected { + Ok(()) + } else { + Err(PlatformError::InvalidValue) + } +} + +fn parse_active_index(raw: &str) -> Result { + raw.split_whitespace() + .find_map(|word| word.strip_prefix('[')?.strip_suffix(']')) + .map(str::to_owned) + .ok_or(PlatformError::InvalidValue) +} + +fn parse_speed_range(raw: &str) -> Result<(u32, u32)> { + let values: Vec<_> = raw + .split(|c: char| !c.is_ascii_digit()) + .filter(|value| !value.is_empty()) + .map(str::parse::) + .collect::>() + .map_err(|_| PlatformError::ParseNum)?; + match values.as_slice() { + [min, max] if min <= max => Ok((*min, *max)), + _ => Err(PlatformError::InvalidValue), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + #[test] + fn test_parse_active_aura_mode() { + let sample = "[auto] unified split\n"; + assert_eq!(super::parse_active_index(sample).unwrap(), "auto"); + + let sample2 = "auto [unified] split\n"; + assert_eq!(super::parse_active_index(sample2).unwrap(), "unified"); + assert!(super::parse_active_index("auto unified split").is_err()); + } + + #[test] + fn test_palette_colors_formatting() { + let colors = [ + (255, 0, 128), + (0, 255, 64), + ]; + let formatted: Vec = colors + .iter() + .map(|(r, g, b)| format!("#{r:02x}{g:02x}{b:02x}")) + .collect(); + let palette_str = formatted.join(" "); + assert_eq!(palette_str, "#ff0080 #00ff40"); + } + + #[test] + fn validates_exact_direct_frame_size() { + assert!(super::validate_direct_len(4, 12).is_ok()); + assert!(super::validate_direct_len(4, 11).is_err()); + assert!(super::validate_direct_len(usize::MAX, 0).is_err()); + } + + #[test] + fn parses_bounded_speed_range() { + assert_eq!(super::parse_speed_range("0 2\n").unwrap(), (0, 2)); + assert_eq!(super::parse_speed_range("[1-4]").unwrap(), (1, 4)); + assert!(super::parse_speed_range("fast slow").is_err()); + assert!(super::parse_speed_range("4 1").is_err()); + } + + #[test] + fn validates_mandatory_generic_attributes() { + let path = + std::env::temp_dir().join(format!("asusctl-dynamic-led-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir(&path).expect("temporary test directory must be creatable"); + for attr in [ + "effect", "effect_index", "zone_type", "led_count", + ] { + fs::write(path.join(attr), b"").expect("temporary attribute must be writable"); + } + assert!(super::DynamicLed::is_dynamic_node(&path)); + fs::remove_file(path.join("zone_type")).expect("test attribute must be removable"); + assert!(!super::DynamicLed::is_dynamic_node(&path)); + fs::remove_dir_all(path).expect("temporary test directory must be removable"); + } +} diff --git a/rog-platform/src/lib.rs b/rog-platform/src/lib.rs index dec0bb861..7a4650cd4 100644 --- a/rog-platform/src/lib.rs +++ b/rog-platform/src/lib.rs @@ -5,6 +5,7 @@ pub mod asus_armoury; pub mod backlight; pub mod cled; pub mod cpu; +pub mod dynamic_led; pub mod error; pub mod gpu_pci; pub mod hid_raw; @@ -16,6 +17,7 @@ pub mod usb_raw; use std::path::Path; +pub use dynamic_led::DynamicLed; use error::{PlatformError, Result}; use log::warn; use platform::PlatformProfile; @@ -74,13 +76,15 @@ pub fn write_attr_num(device: &mut Device, attr_name: &str, value: T) -> Resu where T: std::fmt::Display, { - if device + device .set_attribute_value(attr_name, format!("{value}")) - .is_err() - { - return Err(PlatformError::AttrNotFound(attr_name.to_owned())); - } - Ok(()) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + PlatformError::AttrNotFound(attr_name.to_owned()) + } else { + PlatformError::IoPath(attr_name.to_owned(), e) + } + }) } pub fn read_attr_u8_array(device: &Device, attr_name: &str) -> Result> { From 7c60ab44ca03a31dd97858dc0e3909d2264919a1 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Tue, 8 Sep 2026 16:56:43 +0200 Subject: [PATCH 3/7] feat(rog-aura): add dynamic lighting mode and palette conversions Add mapping functions between ROG Aura structures and Dynamic Lighting class values: - AuraModeNum::{to_dynamic_effect_str, from_dynamic_effect_str} - Speed::{to_dynamic_speed, from_dynamic_speed} - Direction::{to_dynamic_direction_str, from_dynamic_direction_str} - AuraEffect::to_dynamic_palette for extracting RGB palette tuples - LedBrightness::{to_scaled, from_scaled} for mapping Off/Low/Med/High onto Dynamic Lighting 0..=max_brightness sysfs ranges Signed-off-by: Marco Scardovi --- rog-aura/src/builtin_modes.rs | 221 +++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 2 deletions(-) diff --git a/rog-aura/src/builtin_modes.rs b/rog-aura/src/builtin_modes.rs index d34f2b0f3..f8d73efe7 100644 --- a/rog-aura/src/builtin_modes.rs +++ b/rog-aura/src/builtin_modes.rs @@ -40,6 +40,46 @@ impl LedBrightness { Self::High => Self::Med, } } + + /// Map the 4-step UI level onto a sysfs brightness value. + /// + /// When `max_brightness <= 3` the legacy 0..=3 mapping is kept. Otherwise + /// levels are scaled across `0..=max_brightness` (Off=0, High=max). + pub const fn to_scaled(self, max_brightness: u8) -> u8 { + if max_brightness <= 3 { + return self as u8; + } + match self { + Self::Off => 0, + Self::Low => max_brightness / 3, + Self::Med => ((2u16 * max_brightness as u16) / 3) as u8, + Self::High => max_brightness, + } + } + + /// Inverse of [`Self::to_scaled`]. + pub const fn from_scaled(value: u8, max_brightness: u8) -> Self { + if max_brightness <= 3 { + return match value { + 0 => Self::Off, + 1 => Self::Low, + 3 => Self::High, + _ => Self::Med, + }; + } + if value == 0 { + return Self::Off; + } + let low = max_brightness / 3; + let med = ((2u16 * max_brightness as u16) / 3) as u8; + if value <= low { + Self::Low + } else if value <= med { + Self::Med + } else { + Self::High + } + } } impl From for LedBrightness { @@ -199,6 +239,24 @@ impl From for u8 { } } } + +impl Speed { + pub const fn to_dynamic_speed(&self) -> u32 { + match self { + Self::Low => 0, + Self::Med => 1, + Self::High => 2, + } + } + + pub const fn from_dynamic_speed(val: u32) -> Self { + match val { + 0 => Self::Low, + 2 => Self::High, + _ => Self::Med, + } + } +} /// Used for Rainbow mode. /// /// Enum corresponds to the required integer value @@ -248,6 +306,26 @@ impl From for i32 { } } +impl Direction { + pub const fn to_dynamic_direction_str(&self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Up => "up", + Self::Down => "down", + } + } + + pub fn from_dynamic_direction_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "left" => Self::Left, + "up" => Self::Up, + "down" => Self::Down, + _ => Self::Right, + } + } +} + /// Enum of modes that convert to the actual number required by a USB HID packet #[cfg_attr( feature = "dbus", @@ -359,6 +437,32 @@ impl From for AuraModeNum { } } +impl AuraModeNum { + /// Return the corresponding Dynamic Lighting effect name, if available. + pub const fn to_dynamic_effect_str(&self) -> Option<&'static str> { + match self { + Self::Static => Some("static"), + Self::Breathe => Some("breathing"), + Self::RainbowCycle => Some("spectrum_cycle"), + Self::RainbowWave => Some("rainbow"), + Self::Pulse | Self::Flash => Some("strobe"), + _ => None, + } + } + + /// Parse a Dynamic Lighting effect name into an `AuraModeNum`. + pub fn from_dynamic_effect_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "static" => Some(Self::Static), + "breathing" => Some(Self::Breathe), + "spectrum_cycle" => Some(Self::RainbowCycle), + "rainbow" => Some(Self::RainbowWave), + "strobe" => Some(Self::Pulse), + _ => None, + } + } +} + #[cfg(feature = "dbus")] impl zbus::zvariant::Basic for AuraModeNum { const SIGNATURE_CHAR: char = 'u'; @@ -404,7 +508,7 @@ impl FromStr for AuraZone { "3" | "three" => Ok(AuraZone::Key3), "4" | "four" => Ok(AuraZone::Key4), "5" | "logo" => Ok(AuraZone::Logo), - "6" | "lightbar-left" => Ok(AuraZone::BarLeft), + "6" | "lightbar-left" | "lightbar" | "bar" => Ok(AuraZone::BarLeft), "7" | "lightbar-right" => Ok(AuraZone::BarRight), _ => Err(Error::ParseSpeed), } @@ -469,6 +573,16 @@ impl AuraEffect { pub fn zone(&self) -> AuraZone { self.zone } + + /// Convert the effect colours to an array of RGB tuples for Dynamic Lighting palette. + pub fn to_dynamic_palette(&self) -> Vec<(u8, u8, u8)> { + let mut p = Vec::with_capacity(2); + p.push((self.colour1.r, self.colour1.g, self.colour1.b)); + if self.colour2.r != 0 || self.colour2.g != 0 || self.colour2.b != 0 { + p.push((self.colour2.r, self.colour2.g, self.colour2.b)); + } + p + } } impl Default for AuraEffect { @@ -539,9 +653,28 @@ impl From<&AuraEffect> for Vec { #[cfg(test)] mod tests { use crate::{ - AURA_LAPTOP_LED_MSG_LEN, AuraEffect, AuraModeNum, AuraZone, Colour, Direction, Speed, + AURA_LAPTOP_LED_MSG_LEN, AuraEffect, AuraModeNum, AuraZone, Colour, Direction, + LedBrightness, Speed, }; + #[test] + fn led_brightness_scales_for_dynamic_lighting() { + assert_eq!(LedBrightness::Off.to_scaled(255), 0); + assert_eq!(LedBrightness::Low.to_scaled(255), 85); + assert_eq!(LedBrightness::Med.to_scaled(255), 170); + assert_eq!(LedBrightness::High.to_scaled(255), 255); + + assert_eq!(LedBrightness::from_scaled(0, 255), LedBrightness::Off); + assert_eq!(LedBrightness::from_scaled(85, 255), LedBrightness::Low); + assert_eq!(LedBrightness::from_scaled(170, 255), LedBrightness::Med); + assert_eq!(LedBrightness::from_scaled(255, 255), LedBrightness::High); + + // Legacy 0..=3 path when max_brightness is small. + assert_eq!(LedBrightness::Med.to_scaled(3), 2); + assert_eq!(LedBrightness::from_scaled(2, 3), LedBrightness::Med); + assert_eq!(LedBrightness::High.to_scaled(3), 3); + } + #[test] fn check_led_static_packet() { let st = AuraEffect { @@ -689,4 +822,88 @@ mod tests { capture[..9] ); } + + #[test] + fn test_dynamic_lighting_conversions() { + assert_eq!(AuraModeNum::Static.to_dynamic_effect_str(), Some("static")); + assert_eq!( + AuraModeNum::Breathe.to_dynamic_effect_str(), + Some("breathing") + ); + assert_eq!( + AuraModeNum::RainbowCycle.to_dynamic_effect_str(), + Some("spectrum_cycle") + ); + assert_eq!( + AuraModeNum::RainbowWave.to_dynamic_effect_str(), + Some("rainbow") + ); + assert_eq!(AuraModeNum::Pulse.to_dynamic_effect_str(), Some("strobe")); + assert_eq!(AuraModeNum::Flash.to_dynamic_effect_str(), Some("strobe")); + assert_eq!(AuraModeNum::Star.to_dynamic_effect_str(), None); + + assert_eq!( + AuraModeNum::from_dynamic_effect_str("static"), + Some(AuraModeNum::Static) + ); + assert_eq!( + AuraModeNum::from_dynamic_effect_str("breathing"), + Some(AuraModeNum::Breathe) + ); + assert_eq!( + AuraModeNum::from_dynamic_effect_str("spectrum_cycle"), + Some(AuraModeNum::RainbowCycle) + ); + assert_eq!( + AuraModeNum::from_dynamic_effect_str("rainbow"), + Some(AuraModeNum::RainbowWave) + ); + assert_eq!( + AuraModeNum::from_dynamic_effect_str("strobe"), + Some(AuraModeNum::Pulse) + ); + assert_eq!(AuraModeNum::from_dynamic_effect_str("unknown"), None); + + assert_eq!(Speed::Low.to_dynamic_speed(), 0); + assert_eq!(Speed::Med.to_dynamic_speed(), 1); + assert_eq!(Speed::High.to_dynamic_speed(), 2); + assert_eq!(Speed::from_dynamic_speed(0), Speed::Low); + assert_eq!(Speed::from_dynamic_speed(1), Speed::Med); + assert_eq!(Speed::from_dynamic_speed(2), Speed::High); + + assert_eq!(Direction::Right.to_dynamic_direction_str(), "right"); + assert_eq!(Direction::Left.to_dynamic_direction_str(), "left"); + assert_eq!(Direction::Up.to_dynamic_direction_str(), "up"); + assert_eq!(Direction::Down.to_dynamic_direction_str(), "down"); + assert_eq!( + Direction::from_dynamic_direction_str("left"), + Direction::Left + ); + assert_eq!( + Direction::from_dynamic_direction_str("right"), + Direction::Right + ); + + let effect = AuraEffect { + colour1: Colour { + r: 0xff, + g: 0x10, + b: 0x20, + }, + colour2: Colour { + r: 0x00, + g: 0x30, + b: 0x40, + }, + ..Default::default() + }; + let palette = effect.to_dynamic_palette(); + assert_eq!( + palette, + vec![ + (0xff, 0x10, 0x20), + (0x00, 0x30, 0x40) + ] + ); + } } From 21f831411e5825165fcc2feccf00f03043780098 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Sat, 12 Sep 2026 19:13:13 +0200 Subject: [PATCH 4/7] feat(asusd): prefer dynamic lighting with hidraw fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prioritize valid Dynamic Lighting sysfs nodes for laptop Aura control and keep hidraw only as a capability-based fallback when no DL node exists. The two paths stay mutually exclusive for one device. Clean up dead hidraw/comment leftovers in anime and device handles, stream exact led_count*3 RGB payloads through direct_buffer, and honour ASUS aura_mode topology: skip -EBUSY on inactive unified/split nodes, map legacy Key1–4 / bar zones onto keyboard/lightbar nodes, and scale Fn-key brightness across writable DL nodes. Document the userspace DL vs hidraw policy. Signed-off-by: Marco Scardovi --- asusd/src/aura_anime/mod.rs | 19 +- asusd/src/aura_laptop/mod.rs | 653 ++++++++++++++++++++++++--- asusd/src/aura_laptop/trait_impls.rs | 184 ++++---- asusd/src/aura_manager.rs | 72 +-- asusd/src/aura_types.rs | 67 +-- docs/SUMMARY.md | 1 + docs/usage/dynamic-lighting.md | 55 +++ rog-platform/src/hid_raw.rs | 81 +++- 8 files changed, 861 insertions(+), 271 deletions(-) create mode 100644 docs/usage/dynamic-lighting.md diff --git a/asusd/src/aura_anime/mod.rs b/asusd/src/aura_anime/mod.rs index 5d622f3ab..0d3f6230b 100644 --- a/asusd/src/aura_anime/mod.rs +++ b/asusd/src/aura_anime/mod.rs @@ -9,12 +9,12 @@ use std::thread::sleep; use config_traits::StdConfig; use log::{debug, error, info, warn}; +use rog_anime::error::AnimeError; use rog_anime::usb::{ Brightness, pkt_flush, pkt_set_brightness, pkt_set_enable_display, pkt_set_enable_powersave_anim, pkts_for_init, }; use rog_anime::{ActionData, AnimeDataBuffer, AnimePacketType}; -use rog_platform::hid_raw::HidRaw; use rog_platform::usb_raw::USBRaw; use tokio::sync::Mutex; @@ -23,7 +23,6 @@ use crate::error::RogError; #[derive(Debug, Clone)] pub struct AniMe { - hid: Option>>, usb: Option>>, config: Arc>, cache: AniMeConfigCached, @@ -34,13 +33,8 @@ pub struct AniMe { } impl AniMe { - pub fn new( - hid: Option>>, - usb: Option>>, - config: Arc>, - ) -> Self { + pub fn new(usb: Option>>, config: Arc>) -> Self { Self { - hid, usb, config, cache: AniMeConfigCached::default(), @@ -78,11 +72,10 @@ impl AniMe { } pub async fn write_bytes(&self, message: &[u8]) -> Result<(), RogError> { - if let Some(hid) = &self.hid { - hid.lock().await.write_bytes(message)?; - } else if let Some(usb) = &self.usb { - usb.lock().await.write_bytes(message)?; - } + let Some(usb) = &self.usb else { + return Err(RogError::Anime(AnimeError::NoDevice)); + }; + usb.lock().await.write_bytes(message)?; Ok(()) } diff --git a/asusd/src/aura_laptop/mod.rs b/asusd/src/aura_laptop/mod.rs index f6ebef2ca..614c658ad 100644 --- a/asusd/src/aura_laptop/mod.rs +++ b/asusd/src/aura_laptop/mod.rs @@ -2,10 +2,12 @@ use std::sync::Arc; use config::AuraConfig; use config_traits::StdConfig; -use log::info; +use log::{debug, info}; use rog_aura::keyboard::{AuraLaptopUsbPackets, LedUsbPackets}; use rog_aura::usb::{AURA_LAPTOP_LED_APPLY, AURA_LAPTOP_LED_SET}; use rog_aura::{AURA_LAPTOP_LED_MSG_LEN, AuraDeviceType, AuraEffect, LedBrightness, PowerZones}; +use rog_platform::DynamicLed; +use rog_platform::error::PlatformError; use rog_platform::hid_raw::HidRaw; use rog_platform::keyboard_led::KeyboardBacklight; use tokio::sync::{Mutex, MutexGuard}; @@ -17,14 +19,50 @@ pub mod trait_impls; #[derive(Debug, Clone)] pub struct Aura { + pub dynamic_global: Option>>, + pub dynamic_kbd: Option>>, + pub dynamic_lightbar: Option>>, pub hid: Option>>, pub backlight: Option>>, pub config: Arc>, } impl Aura { + #[must_use] + pub fn has_dynamic_lighting(&self) -> bool { + self.dynamic_kbd.is_some() + || self.dynamic_global.is_some() + || self.dynamic_lightbar.is_some() + } + + async fn set_asus_topology(&self, mode: &str) -> Result<(), RogError> { + for led in [ + self.dynamic_global.as_ref(), + self.dynamic_kbd.as_ref(), + self.dynamic_lightbar.as_ref(), + ] + .into_iter() + .flatten() + { + let led = led.lock().await; + if led.has_asus_aura_mode() { + led.set_asus_aura_mode(mode)?; + return Ok(()); + } + } + Err(RogError::MissingFunction( + "ASUS Dynamic Lighting topology control is unavailable".into(), + )) + } + /// Initialise the device if required. pub async fn do_initialization(&self) -> Result<(), RogError> { + // With a chassis lightbar, prefer split so keyboard and lightbar are + // independently writable (matches kernel auto→split). Unified remains + // available when userspace wants a single global effect. + if self.dynamic_lightbar.is_some() { + self.set_asus_topology("split").await?; + } Ok(()) } @@ -35,14 +73,11 @@ impl Aura { /// Will lock the internal config and update. If anything else has locked /// this in scope then a deadlock can occur. pub async fn update_config(&self) -> Result<(), RogError> { + let bright = self.get_brightness().await; let mut config = self.config.lock().await; - let bright = if let Some(bl) = self.backlight.as_ref() { - bl.lock().await.get_brightness().unwrap_or_default() - } else { - config.brightness.into() - }; + let bright = bright.unwrap_or(config.brightness); config.read(); - config.brightness = bright.into(); + config.brightness = bright; config.write(); Ok(()) } @@ -93,82 +128,386 @@ impl Aura { dev_type: AuraDeviceType, mode: &AuraEffect, ) -> Result<(), RogError> { - if matches!(dev_type, AuraDeviceType::LaptopKeyboardTuf) { - if let Some(platform) = &self.backlight { - let buf = [ - 1, mode.mode as u8, mode.colour1.r, mode.colour1.g, mode.colour1.b, - mode.speed as u8, - ]; - platform.lock().await.set_kbd_rgb_mode(&buf)?; + // Priority: Dynamic Lighting sysfs interface. + // Key1–4 map to the whole keyboard node; BarLeft/BarRight to lightbar. + // Legacy Logo / other zones remain unsupported under DL. + if self.has_dynamic_lighting() && !supports_dynamic_zone(mode.zone) { + return Err(RogError::MissingFunction( + "Dynamic Lighting exposes keyboard/lightbar topology, not legacy Aura subzones" + .into(), + )); + } + if self.has_dynamic_lighting() + && let Some(eff_str) = mode.mode.to_dynamic_effect_str() + { + let speed = mode.speed.to_dynamic_speed(); + let dir_str = mode.direction.to_dynamic_direction_str(); + let palette = mode.to_dynamic_palette(); + + let apply_to_led = |led: &DynamicLed| -> Result { + if !led.supports_effect(eff_str)? { + return Ok(false); + } + if led.has_speed() { + match led.set_supported_speed(speed) { + Ok(()) => {} + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + return Ok(false); + } + return Err(err); + } + } + } + if led.has_direction() { + match led.set_supported_direction(dir_str) { + Ok(()) => {} + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + return Ok(false); + } + return Err(err); + } + } + } + if led.has_effects_palette() && !palette.is_empty() { + match led.set_palette_colors(&palette) { + Ok(()) => {} + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + return Ok(false); + } + return Err(err); + } + } + } + match led.set_effect(eff_str) { + Ok(()) => Ok(true), + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + Ok(false) + } else { + Err(err) + } + } + } + }; + + match mode.zone { + rog_aura::AuraZone::BarLeft | rog_aura::AuraZone::BarRight => { + self.set_asus_topology("split").await?; + if let Some(lb) = &self.dynamic_lightbar { + let led = lb.lock().await; + if apply_to_led(&led)? { + return Ok(()); + } + } + } + rog_aura::AuraZone::None => { + // Whole-device: only unified topology uses aura:global. + // Otherwise (split / auto→split) fan out to keyboard and + // lightbar so both zones get the same effect. + let unified = if let Some(global) = &self.dynamic_global { + let led = global.lock().await; + led.has_asus_aura_mode() + && led.get_asus_aura_mode().ok().as_deref() == Some("unified") + } else { + false + }; + if unified && let Some(global) = &self.dynamic_global { + let global_led = global.lock().await; + if apply_to_led(&global_led)? { + return Ok(()); + } + } + if let Some(kbd) = &self.dynamic_kbd { + let kbd_led = kbd.lock().await; + let mut any = apply_to_led(&kbd_led)?; + if let Some(lb) = &self.dynamic_lightbar { + let lb_led = lb.lock().await; + any |= apply_to_led(&lb_led)?; + } + if any { + return Ok(()); + } + } + } + // Key1–4: whole keyboard node under DL (no legacy 4-zone split). + _ => { + if self.dynamic_lightbar.is_some() { + self.set_asus_topology("split").await?; + } + if let Some(kbd) = &self.dynamic_kbd { + let kbd_led = kbd.lock().await; + if apply_to_led(&kbd_led)? { + return Ok(()); + } + } + } } + } + + // When Dynamic Lighting is active, do not fall back to raw hidraw or TUF + // platform + if self.has_dynamic_lighting() { + return Err(RogError::MissingFunction( + "Dynamic lighting mode or zone not supported by kernel".to_string(), + )); + } + + // Fallback is selected only when no valid Dynamic Lighting node exists. + if matches!(dev_type, AuraDeviceType::LaptopKeyboardTuf) + && let Some(platform) = &self.backlight + { + let buf = [ + 1, mode.mode as u8, mode.colour1.r, mode.colour1.g, mode.colour1.b, + mode.speed as u8, + ]; + platform.lock().await.set_kbd_rgb_mode(&buf)?; + return Ok(()); } else if let Some(hid_raw) = &self.hid { - // Some keyboard controllers (e.g. G533QS firmware) silently drop - // short HID writes and only honour packets matching the OUTPUT - // report size declared in the HID descriptor (64 bytes for the - // 0x5d report). Pad effect/SET/APPLY here so we keep working on - // newer Strix/Zephyrus models without regressing older laptops. const PADDED_LEN: usize = 64; let bytes: [u8; AURA_LAPTOP_LED_MSG_LEN] = mode.into(); let mut effect_padded = [0u8; PADDED_LEN]; - effect_padded[..AURA_LAPTOP_LED_MSG_LEN].copy_from_slice(&bytes); + effect_padded[..bytes.len()].copy_from_slice(&bytes); let mut set_padded = [0u8; PADDED_LEN]; - set_padded[..AURA_LAPTOP_LED_MSG_LEN].copy_from_slice(&AURA_LAPTOP_LED_SET); + set_padded[..AURA_LAPTOP_LED_SET.len()].copy_from_slice(&AURA_LAPTOP_LED_SET); let mut apply_padded = [0u8; PADDED_LEN]; - apply_padded[..AURA_LAPTOP_LED_MSG_LEN].copy_from_slice(&AURA_LAPTOP_LED_APPLY); + apply_padded[..AURA_LAPTOP_LED_APPLY.len()].copy_from_slice(&AURA_LAPTOP_LED_APPLY); let hid_raw = hid_raw.lock().await; hid_raw.write_bytes(&effect_padded)?; hid_raw.write_bytes(&set_padded)?; - // Changes won't persist unless apply is set hid_raw.write_bytes(&apply_padded)?; - } else { - return Err(RogError::NoAuraKeyboard); + return Ok(()); } - Ok(()) + Err(RogError::NoAuraKeyboard) + } + + /// Nodes that hold live brightness for the current ASUS topology. + /// Kernel `auto` resolves to split, so keyboard/lightbar are canonical. + async fn topology_brightness_slots(&self) -> [Option<&Arc>>; 3] { + let mut mode = None; + for slot in [ + self.dynamic_global.as_ref(), + self.dynamic_kbd.as_ref(), + self.dynamic_lightbar.as_ref(), + ] + .into_iter() + .flatten() + { + let led = slot.lock().await; + if led.has_asus_aura_mode() { + mode = led.get_asus_aura_mode().ok(); + break; + } + } + match mode.as_deref() { + Some("unified") => [ + self.dynamic_global.as_ref(), + None, + None, + ], + Some("split") | Some("auto") => [ + self.dynamic_kbd.as_ref(), + self.dynamic_lightbar.as_ref(), + None, + ], + _ => [ + self.dynamic_kbd.as_ref(), + self.dynamic_lightbar.as_ref(), + self.dynamic_global.as_ref(), + ], + } } - pub async fn set_brightness(&self, value: u8) -> Result<(), RogError> { + /// Read the current brightness as a 4-step [`LedBrightness`] level. + /// + /// Dynamic Lighting nodes may advertise `max_brightness > 3`; those values + /// are scaled back to Off/Low/Med/High. LED class reads do not return + /// `-EBUSY`, so inactive nodes can still look readable — follow `aura_mode` + /// (kernel `auto` is split) instead of trusting the first node that replies. + pub async fn get_brightness(&self) -> Result { + for slot in self.topology_brightness_slots().await.into_iter().flatten() { + let led = slot.lock().await; + let max = match led.get_max_brightness() { + Ok(m) => m, + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + continue; + } + return Err(err); + } + }; + match led.get_brightness() { + Ok(value) => return Ok(LedBrightness::from_scaled(value, max)), + Err(e) => { + let err = RogError::from(e); + if is_dl_node_inactive(&err) { + continue; + } + return Err(err); + } + } + } if let Some(backlight) = &self.backlight { - backlight.lock().await.set_brightness(value)?; - return Ok(()); + let value = backlight.lock().await.get_brightness()?; + return Ok(value.into()); } Err(RogError::MissingFunction( "No LED backlight control available".to_string(), )) } + /// Set keyboard/Aura brightness from a 4-step [`LedBrightness`] level. + /// + /// When Dynamic Lighting nodes are present the value is scaled to each + /// *active* node's `max_brightness`. Under ASUS `aura_mode=unified` only + /// `aura:global` accepts writes (`keyboard`/`lightbar` return `-EBUSY`); + /// under `split` the reverse is true. Inactive-node errors are skipped so + /// Fn-key brightness works in either topology. The legacy + /// `KeyboardBacklight` (0..=3) is also updated when present. + pub async fn set_brightness(&self, brightness: LedBrightness) -> Result<(), RogError> { + let mut applied = false; + for led in [ + &self.dynamic_global, + &self.dynamic_kbd, + &self.dynamic_lightbar, + ] + .into_iter() + .flatten() + { + let led = led.lock().await; + let max = led.get_max_brightness()?; + match led.set_brightness(brightness.to_scaled(max)) { + Ok(()) => applied = true, + Err(e) => { + let err = RogError::from(e); + if !is_dl_node_inactive(&err) { + return Err(err); + } + } + } + } + + if let Some(backlight) = &self.backlight { + match backlight.lock().await.set_brightness(brightness.into()) { + Ok(()) => applied = true, + Err(e) if applied => { + debug!("WMI kbd backlight brightness sync failed: {e}"); + } + Err(e) => return Err(e.into()), + } + } + + if applied { + Ok(()) + } else { + Err(RogError::MissingFunction( + "No LED backlight control available".to_string(), + )) + } + } + /// Set combination state for boot animation/sleep animation/all leds/keys /// leds/side leds LED active pub async fn set_power_states(&self, config: &AuraConfig) -> Result<(), RogError> { - if matches!(config.led_type, rog_aura::AuraDeviceType::LaptopKeyboardTuf) { - if let Some(backlight) = &self.backlight { - // TODO: tuf bool array - let buf = config.enabled.to_bytes(config.led_type); - backlight.lock().await.set_kbd_rgb_state(&buf)?; + if self.has_dynamic_lighting() { + let mut requested = Vec::new(); + for state in &config.enabled.states { + if state.boot { + requested.push("boot"); + } + if state.awake { + requested.push("awake"); + } + if state.sleep { + requested.push("sleep"); + } + if state.shutdown { + requested.push("shutdown"); + } + } + let mut applied = false; + let mut saw_power_states = false; + let mut unsupported_request = false; + for slot in [ + &self.dynamic_global, + &self.dynamic_kbd, + &self.dynamic_lightbar, + ] { + let Some(led) = slot else { + continue; + }; + let led = led.lock().await; + if !led.has_power_states() { + continue; + } + saw_power_states = true; + let states = filter_power_states(&requested, &led.get_power_states_index()?); + if states.is_empty() && !requested.is_empty() { + unsupported_request = true; + continue; + } + match led.set_power_states(&states.join(" ")) { + Ok(()) => applied = true, + Err(e) => { + let err = RogError::from(e); + if !is_dl_node_inactive(&err) { + return Err(err); + } + } + } + } + if applied { + return Ok(()); + } + if !saw_power_states { + return Err(RogError::MissingFunction( + "Dynamic Lighting node does not expose power_states".into(), + )); + } + if unsupported_request { + return Err(RogError::MissingFunction( + "No requested power state is supported by this Dynamic Lighting node".into(), + )); } + return Err(RogError::MissingFunction( + "No active Dynamic Lighting node accepted power_states".into(), + )); + } + + if matches!(config.led_type, rog_aura::AuraDeviceType::LaptopKeyboardTuf) + && let Some(backlight) = &self.backlight + { + // TODO: tuf bool array + let buf = config.enabled.to_bytes(config.led_type); + backlight.lock().await.set_kbd_rgb_state(&buf)?; } else if let Some(hid_raw) = &self.hid { let hid_raw = hid_raw.lock().await; if let Some(p) = config.enabled.states.first() && p.zone == PowerZones::Ally { - let msg = [ + hid_raw.write_bytes(&[ 0x5d, 0xd1, 0x09, 0x01, p.new_to_byte() as u8, - 0x0, - 0x0, - ]; - hid_raw.write_bytes(&msg)?; + 0, + 0, + ])?; return Ok(()); } - let bytes = config.enabled.to_bytes(config.led_type); - let msg = [ + hid_raw.write_bytes(&[ 0x5d, 0xbd, 0x01, bytes[0], bytes[1], bytes[2], bytes[3], - ]; - hid_raw.write_bytes(&msg)?; + ])?; } Ok(()) } @@ -186,56 +525,222 @@ impl Aura { config.write(); } - let pkt_type = effect[0][1]; - const PER_KEY_TYPE: u8 = 0xbc; + if matches!(config.led_type, rog_aura::AuraDeviceType::LaptopKeyboardTuf) + && !self.has_dynamic_lighting() + && let Some(tuf) = &self.backlight + { + for row in effect.iter() { + let rgb = row.get(9..12).ok_or(PlatformError::InvalidValue)?; + let [r, g, b] = rgb else { + return Err(PlatformError::InvalidValue.into()); + }; + tuf.lock().await.set_kbd_rgb_mode(&[ + 0, 0, *r, *g, *b, 0, + ])?; + } + return Ok(()); + } + + let dynamic_leds = [ + self.dynamic_kbd.as_ref(), + self.dynamic_global.as_ref(), + ]; + for maybe_led in dynamic_leds.into_iter().flatten() { + let dynamic = maybe_led.lock().await; + let led_count = dynamic.get_led_count()? as usize; + let rgb_buf = direct_rgb_payload(effect, led_count)?; + if !rgb_buf.is_empty() { + match dynamic.write_direct(&rgb_buf) { + Ok(()) => { + config.per_key_mode_active = true; + return Ok(()); + } + Err(PlatformError::IoPath(_, ref e)) + if e.kind() == std::io::ErrorKind::ResourceBusy + || e.raw_os_error() == Some(16) => + { + debug!( + "Dynamic lighting node '{}' busy (-EBUSY), trying alternate", + dynamic.name() + ); + continue; + } + Err(e) => return Err(e.into()), + } + } + } + if self.has_dynamic_lighting() { + return Err(RogError::MissingFunction( + "No active Dynamic Lighting node accepted the direct frame".into(), + )); + } if let Some(hid_raw) = &self.hid { + let first = effect.first().ok_or(PlatformError::InvalidValue)?; + let packet_type = *first.get(1).ok_or(PlatformError::InvalidValue)?; + if effect.iter().any(|row| { + row.first() != Some(&0x5d) + || row.len() > 64 + || (packet_type == 0xbc && row.len() != 64) + }) { + return Err(PlatformError::InvalidValue.into()); + } let hid_raw = hid_raw.lock().await; - if pkt_type != PER_KEY_TYPE { + if packet_type != 0xbc { config.per_key_mode_active = false; - hid_raw.write_bytes(&effect[0])?; + hid_raw.write_bytes(first)?; hid_raw.write_bytes(&AURA_LAPTOP_LED_SET)?; - // hid_raw.write_bytes(&LED_APPLY)?; } else { if !config.per_key_mode_active { - let init = LedUsbPackets::get_init_msg(); - hid_raw.write_bytes(&init)?; + hid_raw.write_bytes(&LedUsbPackets::get_init_msg())?; config.per_key_mode_active = true; } - for row in effect.iter() { + for row in effect { hid_raw.write_bytes(row)?; } } - } else if matches!(config.led_type, rog_aura::AuraDeviceType::LaptopKeyboardTuf) - && let Some(tuf) = &self.backlight - { - for row in effect.iter() { - let r = row[9]; - let g = row[10]; - let b = row[11]; - tuf.lock().await.set_kbd_rgb_mode(&[ - 0, 0, r, g, b, 0, - ])?; - } + return Ok(()); } - Ok(()) + + Err(RogError::NoAuraKeyboard) } pub async fn fix_ally_power(&mut self) -> Result<(), RogError> { - if self.config.lock().await.led_type == AuraDeviceType::Ally - && let Some(hid_raw) = &self.hid - { + let needs_fix = { + let config = self.config.lock().await; + config.led_type == AuraDeviceType::Ally && config.ally_fix.is_none() + }; + if needs_fix && let Some(hid_raw) = &self.hid { + hid_raw.lock().await.write_bytes(&[ + 0x5d, 0xbd, 0x01, 0xff, 0xff, 0xff, 0xff, + ])?; let mut config = self.config.lock().await; - if config.ally_fix.is_none() { - let msg = [ - 0x5d, 0xbd, 0x01, 0xff, 0xff, 0xff, 0xff, - ]; - hid_raw.lock().await.write_bytes(&msg)?; - info!("Reset Ally power settings to base"); - config.ally_fix = Some(true); - } + config.ally_fix = Some(true); config.write(); } Ok(()) } } + +fn direct_rgb_payload( + packets: &AuraLaptopUsbPackets, + led_count: usize, +) -> Result, PlatformError> { + let expected = led_count + .checked_mul(3) + .ok_or(PlatformError::InvalidValue)?; + let first = packets.first().ok_or(PlatformError::InvalidValue)?; + let mut rgb = Vec::with_capacity(expected); + if packets.len() == 1 && led_count == 4 { + rgb.extend_from_slice(first.get(9..21).ok_or(PlatformError::InvalidValue)?); + } else { + for packet in packets { + if packet.get(1).copied() != Some(0xbc) { + return Err(PlatformError::InvalidValue); + } + let count = *packet.get(7).ok_or(PlatformError::InvalidValue)? as usize; + let payload_len = count.checked_mul(3).ok_or(PlatformError::InvalidValue)?; + let end = 9usize + .checked_add(payload_len) + .ok_or(PlatformError::InvalidValue)?; + rgb.extend_from_slice(packet.get(9..end).ok_or(PlatformError::InvalidValue)?); + } + } + if rgb.len() != expected { + return Err(PlatformError::InvalidValue); + } + Ok(rgb) +} + +pub(super) fn supports_dynamic_zone(zone: rog_aura::AuraZone) -> bool { + matches!( + zone, + rog_aura::AuraZone::None + | rog_aura::AuraZone::Key1 + | rog_aura::AuraZone::Key2 + | rog_aura::AuraZone::Key3 + | rog_aura::AuraZone::Key4 + | rog_aura::AuraZone::BarLeft + | rog_aura::AuraZone::BarRight + ) +} + +/// Kernel returns `-EBUSY` when writing a Dynamic Lighting node that is inactive +/// for the current `aura_mode` (`unified` vs `split`). +fn is_dl_node_inactive(err: &RogError) -> bool { + let io = match err { + RogError::Platform(PlatformError::IoPath(_, e)) => Some(e), + RogError::Platform(PlatformError::Io(e)) => Some(e), + RogError::Write(_, e) | RogError::Path(_, e) | RogError::Io(e) => Some(e), + _ => None, + }; + io.is_some_and(|e| e.kind() == std::io::ErrorKind::ResourceBusy) +} + +fn filter_power_states<'a>(requested: &[&'a str], supported: &str) -> Vec<&'a str> { + let supported: std::collections::HashSet<_> = supported.split_whitespace().collect(); + requested + .iter() + .copied() + .filter(|state| supported.contains(state)) + .collect() +} + +#[cfg(test)] +mod tests { + use std::io::{Error, ErrorKind}; + + use rog_aura::AuraZone; + use rog_platform::error::PlatformError; + + use super::{ + direct_rgb_payload, filter_power_states, is_dl_node_inactive, supports_dynamic_zone, + }; + use crate::error::RogError; + + #[test] + fn direct_payload_requires_exact_size() { + let mut packet = vec![0; 15]; + packet[1] = 0xbc; + packet[7] = 2; + packet[9..15].copy_from_slice(&[ + 1, 2, 3, 4, 5, 6, + ]); + assert_eq!( + direct_rgb_payload(&vec![packet.clone()], 2).unwrap().len(), + 6 + ); + assert!(direct_rgb_payload(&vec![packet], 3).is_err()); + assert!(direct_rgb_payload(&Vec::new(), 1).is_err()); + } + + #[test] + fn power_states_are_intersected_with_capabilities() { + assert_eq!( + filter_power_states( + &[ + "boot", "awake", "shutdown" + ], + "awake sleep" + ), + ["awake"] + ); + } + + #[test] + fn dynamic_topology_allows_keyboard_and_lightbar_proxies() { + assert!(supports_dynamic_zone(AuraZone::None)); + assert!(supports_dynamic_zone(AuraZone::Key1)); + assert!(supports_dynamic_zone(AuraZone::BarLeft)); + assert!(!supports_dynamic_zone(AuraZone::Logo)); + } + + #[test] + fn inactive_dl_node_detects_ebusy() { + let err = RogError::Platform(PlatformError::IoPath( + "aura:keyboard/brightness".into(), + Error::new(ErrorKind::ResourceBusy, "Device or resource busy"), + )); + assert!(is_dl_node_inactive(&err)); + } +} diff --git a/asusd/src/aura_laptop/trait_impls.rs b/asusd/src/aura_laptop/trait_impls.rs index 90629044d..8635f1db8 100644 --- a/asusd/src/aura_laptop/trait_impls.rs +++ b/asusd/src/aura_laptop/trait_impls.rs @@ -9,7 +9,7 @@ use zbus::object_server::SignalEmitter; use zbus::zvariant::OwnedObjectPath; use zbus::{Connection, interface}; -use super::Aura; +use super::{Aura, supports_dynamic_zone}; use crate::error::RogError; use crate::{CtrlTask, Reloadable}; @@ -27,11 +27,8 @@ impl AuraZbus { pub async fn start_tasks( mut self, connection: &Connection, - // _signal_ctx: SignalEmitter<'static>, path: OwnedObjectPath, ) -> Result<(), RogError> { - // let task = zbus.clone(); - // let signal_ctx = signal_ctx.clone(); self.reload() .await .unwrap_or_else(|err| warn!("Controller error: {}", err)); @@ -39,11 +36,11 @@ impl AuraZbus { .object_server() .at(path.clone(), self) .await - .map_err(|e| error!("Couldn't add server at path: {path}, {e:?}")) - .ok(); - // TODO: skip this until we keep handles to tasks so they can be killed - // task.create_tasks(signal_ctx).await - Ok(()) + .map_err(|e| { + error!("Couldn't add server at path: {path}, {e:?}"); + RogError::from(e) + }) + .map(|_| ()) } } @@ -61,25 +58,17 @@ impl AuraZbus { /// Return the current LED brightness #[zbus(property)] async fn brightness(&self) -> Result { - if let Some(bl) = self.0.backlight.as_ref() { - return Ok(bl.lock().await.get_brightness().map(|n| n.into())?); - } - Err(ZbErr::Failed("No sysfs brightness control".to_string())) + Ok(self.0.get_brightness().await?) } - /// Set the keyboard brightness level (0-3) + /// Set the keyboard brightness level (Off/Low/Med/High) #[zbus(property)] async fn set_brightness(&mut self, brightness: LedBrightness) -> Result<(), ZbErr> { - if let Some(bl) = self.0.backlight.as_ref() { - let res = bl.lock().await.set_brightness(brightness.into()); - if res.is_ok() { - let mut config = self.0.config.lock().await; - config.brightness = brightness; - config.write(); - } - return Ok(res?); - } - Err(ZbErr::Failed("No sysfs brightness control".to_string())) + self.0.set_brightness(brightness).await?; + let mut config = self.0.config.lock().await; + config.brightness = brightness; + config.write(); + Ok(()) } /// Total levels of brightness available @@ -97,17 +86,55 @@ impl AuraZbus { #[zbus(property)] async fn supported_basic_modes(&self) -> Result, ZbErr> { let config = self.0.config.lock().await; + if self.0.has_dynamic_lighting() { + let led_lock = if let Some(global) = &self.0.dynamic_global { + Some(global.lock().await) + } else if let Some(kbd) = &self.0.dynamic_kbd { + Some(kbd.lock().await) + } else { + None + }; + if let Some(led) = led_lock { + let mut modes = Vec::new(); + for mode in config.builtins.keys() { + if let Some(eff_str) = mode.to_dynamic_effect_str() + && led.supports_effect(eff_str)? + { + modes.push(*mode); + } + } + return Ok(modes); + } + } Ok(config.builtins.keys().cloned().collect()) } #[zbus(property)] async fn supported_basic_zones(&self) -> Result, ZbErr> { + if self.0.has_dynamic_lighting() { + // The kernel nodes split keyboard from lightbar, but do not model + // the historical four keyboard or left/right lightbar zones. + return Ok(Vec::new()); + } let config = self.0.config.lock().await; Ok(config.support_data.basic_zones.clone()) } #[zbus(property)] async fn supported_power_zones(&self) -> Result, ZbErr> { + if self.0.has_dynamic_lighting() { + let has_power_states = if let Some(global) = &self.0.dynamic_global { + global.lock().await.has_power_states() + } else if let Some(kbd) = &self.0.dynamic_kbd { + kbd.lock().await.has_power_states() + } else { + false + }; + if !has_power_states { + // Avoid advertising zones the UI cannot actually control. + return Ok(Vec::new()); + } + } let config = self.0.config.lock().await; Ok(config.support_data.power_zones.clone()) } @@ -116,8 +143,6 @@ impl AuraZbus { #[zbus(property)] async fn led_mode(&self) -> Result { // entirely possible to deadlock here, so use try instead of lock() - // let ctrl = self.0.lock().await; - // Ok(config.current_mode) if let Ok(config) = self.0.config.try_lock() { Ok(config.current_mode) } else { @@ -137,7 +162,7 @@ impl AuraZbus { if config.brightness == LedBrightness::Off { config.brightness = LedBrightness::Med; } - if let Err(e) = self.0.set_brightness(config.brightness.into()).await { + if let Err(e) = self.0.set_brightness(config.brightness).await { log::warn!("Could not set keyboard backlight brightness: {e}"); } config.write(); @@ -166,10 +191,35 @@ impl AuraZbus { #[zbus(property)] async fn set_led_mode_data(&mut self, effect: AuraEffect) -> Result<(), ZbErr> { let mut config = self.0.config.lock().await; - if !config.support_data.basic_modes.contains(&effect.mode) - || effect.zone != AuraZone::None - && !config.support_data.basic_zones.contains(&effect.zone) - { + let (is_mode_supported, is_zone_supported) = if self.0.has_dynamic_lighting() { + let mode_ok = if let Some(eff_str) = effect.mode.to_dynamic_effect_str() { + let led_lock = if let Some(global) = &self.0.dynamic_global { + Some(global.lock().await) + } else if let Some(kbd) = &self.0.dynamic_kbd { + Some(kbd.lock().await) + } else { + None + }; + match led_lock { + Some(led) => led.supports_effect(eff_str)?, + None => false, + } + } else { + false + }; + let zone_ok = supports_dynamic_zone(effect.zone); + (mode_ok, zone_ok) + } else { + ( + config.support_data.basic_modes.contains(&effect.mode), + effect.zone == AuraZone::None + || config.support_data.basic_zones.contains(&effect.zone) + || (self.0.dynamic_lightbar.is_some() + && matches!(effect.zone, AuraZone::BarLeft | AuraZone::BarRight)), + ) + }; + + if !is_mode_supported || !is_zone_supported { return Err(ZbErr::NotSupported(format!( "The Aura effect is not supported: {effect:?}" ))); @@ -181,7 +231,7 @@ impl AuraZbus { if config.brightness == LedBrightness::Off { config.brightness = LedBrightness::Med; } - if let Err(e) = self.0.set_brightness(config.brightness.into()).await { + if let Err(e) = self.0.set_brightness(config.brightness).await { log::warn!("Could not set keyboard backlight brightness: {e}"); } config.set_builtin(effect); @@ -250,35 +300,16 @@ impl CtrlTask for AuraZbus { async move { if !sleeping { info!("CtrlKbdLedTask reloading brightness and modes"); - if let Some(backlight) = &inner1.backlight { - backlight - .lock() - .await - .set_brightness(inner1.config.lock().await.brightness.into()) - .map_err(|e| { - error!("CtrlKbdLedTask: {e}"); - e - }) - .unwrap(); + let brightness = inner1.config.lock().await.brightness; + if let Err(e) = inner1.set_brightness(brightness).await { + error!("CtrlKbdLedTask: {e}"); } let mut config = inner1.config.lock().await; - inner1 - .write_current_config_mode(&mut config) - .await - .map_err(|e| { - error!("CtrlKbdLedTask: {e}"); - e - }) - .unwrap(); - } else if sleeping { - inner1 - .update_config() - .await - .map_err(|e| { - error!("CtrlKbdLedTask: {e}"); - e - }) - .unwrap(); + if let Err(e) = inner1.write_current_config_mode(&mut config).await { + error!("CtrlKbdLedTask: {e}"); + } + } else if let Err(e) = inner1.update_config().await { + error!("CtrlKbdLedTask: {e}"); } } }, @@ -286,17 +317,9 @@ impl CtrlTask for AuraZbus { let inner3 = inner3.clone(); async move { info!("CtrlKbdLedTask reloading brightness and modes"); - if let Some(backlight) = &inner3.backlight { - // unwrap as we want to bomb out of the task - backlight - .lock() - .await - .set_brightness(inner3.config.lock().await.brightness.into()) - .map_err(|e| { - error!("CtrlKbdLedTask: {e}"); - e - }) - .unwrap(); + let brightness = inner3.config.lock().await.brightness; + if let Err(e) = inner3.set_brightness(brightness).await { + error!("CtrlKbdLedTask: {e}"); } } }, @@ -311,27 +334,6 @@ impl CtrlTask for AuraZbus { ) .await; - // let ctrl2 = self.0.clone(); - // let ctrl = self.0.lock().await; - // if ctrl.led_node.has_brightness_control() { - // let watch = ctrl.led_node.monitor_brightness()?; - // tokio::spawn(async move { - // let mut buffer = [0; 32]; - // watch - // .into_event_stream(&mut buffer) - // .unwrap() - // .for_each(|_| async { - // if let Some(lock) = ctrl2.try_lock() { - // load_save(true, lock).unwrap(); // unwrap as we want - // // to - // // bomb out of the - // // task - // } - // }) - // .await; - // }); - // } - Ok(()) } } diff --git a/asusd/src/aura_manager.rs b/asusd/src/aura_manager.rs index d809bd744..7cebf5caf 100644 --- a/asusd/src/aura_manager.rs +++ b/asusd/src/aura_manager.rs @@ -7,7 +7,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use dmi_id::DMIID; use log::{debug, error, info, warn}; use mio::{Events, Interest, Poll, Token}; use rog_platform::error::PlatformError; @@ -196,29 +195,6 @@ impl DeviceManager { }); } } - // ANIME MATRIX DEVICE - if let Ok(dev_type) = - DeviceHandle::maybe_anime_hid(dev.clone(), usb_id.to_str().unwrap_or_default()) - .await - && let DeviceHandle::AniMe(anime) = dev_type.clone() - { - let path = dbus_path_for_dev(&usb_device).unwrap_or(dbus_path_for_anime()); - let ctrl = AniMeZbus::new(anime); - if ctrl - .start_tasks(connection, path.clone()) - .await - .map_err(|e| { - error!("Failed to start AniMe tasks: {e:?}, not adding this device") - }) - .is_ok() - { - devices.push(AsusDevice { - device: dev_type, - dbus_path: path, - hid_key: Some(hid_key.clone()), - }); - } - } // AURA LAPTOP DEVICE if let Ok(dev_type) = DeviceHandle::maybe_laptop_aura(Some(dev), usb_id.to_str().unwrap_or_default()) @@ -450,7 +426,7 @@ impl DeviceManager { if matches!(dev.device, DeviceHandle::AniMe(_)) { do_anime = false; } - if matches!(dev.device, DeviceHandle::Aura(_) | DeviceHandle::OldAura(_)) { + if matches!(dev.device, DeviceHandle::Aura(_)) { do_kb_backlight = false; } } @@ -505,35 +481,25 @@ impl DeviceManager { } if do_kb_backlight { - // TUF AURA LAPTOP DEVICE - // product_name = ASUS TUF Gaming F15 FX507ZE_FX507ZE - // product_family = ASUS TUF Gaming F15 - let product_name = DMIID::new().unwrap_or_default().product_name; - let product_family = DMIID::new().unwrap_or_default().product_family; - info!( - "No USB keyboard aura, system is {product_name}, try using sysfs backlight control" - ); - if product_name.contains("TUF") || product_family.contains("TUF") { - info!("TUF laptop, try using sysfs backlight control"); - if let Ok(dev_type) = DeviceHandle::maybe_laptop_aura(None, "tuf").await - && let DeviceHandle::Aura(aura) = dev_type.clone() + info!("No USB keyboard aura, try sysfs backlight / Dynamic Lighting"); + if let Ok(dev_type) = DeviceHandle::maybe_laptop_aura(None, "tuf").await + && let DeviceHandle::Aura(aura) = dev_type.clone() + { + let path = dbus_path_for_tuf(); + let ctrl = AuraZbus::new(aura); + if ctrl + .start_tasks(connection, path.clone()) + .await + .map_err(|e| { + error!("Failed to start TUF Aura tasks: {e:?}, not adding this device") + }) + .is_ok() { - let path = dbus_path_for_tuf(); - let ctrl = AuraZbus::new(aura); - if ctrl - .start_tasks(connection, path.clone()) - .await - .map_err(|e| { - error!("Failed to start TUF Aura tasks: {e:?}, not adding this device") - }) - .is_ok() - { - devices.push(AsusDevice { - device: dev_type, - dbus_path: path, - hid_key: None, - }); - } + devices.push(AsusDevice { + device: dev_type, + dbus_path: path, + hid_key: None, + }); } } } diff --git a/asusd/src/aura_types.rs b/asusd/src/aura_types.rs index c31de89a0..0c8f01872 100644 --- a/asusd/src/aura_types.rs +++ b/asusd/src/aura_types.rs @@ -6,6 +6,7 @@ use rog_anime::AnimeType; use rog_anime::error::AnimeError; use rog_anime::usb::get_anime_type; use rog_aura::AuraDeviceType; +use rog_platform::DynamicLed; use rog_platform::hid_raw::HidRaw; use rog_platform::keyboard_led::KeyboardBacklight; use rog_platform::usb_raw::USBRaw; @@ -41,17 +42,13 @@ pub enum DeviceHandle { /// The AniMe devices require USBRaw as they are not HID devices AniMe(AniMe), Scsi(ScsiAura), - Ally(Arc>), - OldAura(Arc>), - /// TUF laptops have an aditional set of attributes added to the LED /sysfs/ - TufLedClass(Arc>), /// TODO MulticolourLed, None, } impl DeviceHandle { - /// Try Slash HID. If one exists it is initialsed and returned. + /// Try Slash HID. If one exists it is initialised and returned. pub async fn new_slash_hid( device: Arc>, prod_id: &str, @@ -77,7 +74,7 @@ impl DeviceHandle { Ok(Self::Slash(slash)) } - /// Try Slash USB. If one exists it is initialsed and returned. + /// Try Slash USB. If one exists it is initialised and returned. pub async fn new_slash_usb() -> Result { debug!("Testing for USB Slash"); let slash_type = SlashType::from_dmi(); @@ -102,32 +99,6 @@ impl DeviceHandle { } } - /// Try AniMe Matrix HID. If one exists it is initialsed and returned. - pub async fn maybe_anime_hid( - _device: Arc>, - _prod_id: &str, - ) -> Result { - // TODO: can't use HIDRAW for anime at the moment - Err(RogError::NotFound( - "Can't use anime over hidraw yet. Skip.".to_string(), - )) - - // debug!("Testing for HIDRAW AniMe"); - // let anime_type = AnimeType::from_dmi(); - // dbg!(prod_id); - // if matches!(anime_type, AnimeType::Unsupported) || prod_id != "193b" - // { log::info!("Unknown or invalid AniMe: {prod_id:?}, - // skipping"); return Err(RogError::NotFound("No - // anime-matrix device".to_string())); } - // info!("Found AniMe Matrix HIDRAW {anime_type:?}: {prod_id}"); - - // let mut config = AniMeConfig::new().load(); - // config.anime_type = anime_type; - // let mut anime = AniMe::new(Some(device), None, - // Arc::new(Mutex::new(config))); anime.do_initialization(). - // await?; Ok(Self::AniMe(anime)) - } - pub async fn maybe_anime_usb() -> Result { debug!("Testing for USB AniMe"); let anime_type = get_anime_type(); @@ -142,7 +113,6 @@ impl DeviceHandle { let mut config = AniMeConfig::new().load(); config.anime_type = anime_type; let mut anime = AniMe::new( - None, Some(Arc::new(Mutex::new(usb))), Arc::new(Mutex::new(config)), ); @@ -197,10 +167,41 @@ impl DeviceHandle { Some(Arc::new(Mutex::new(k))) }); + // Check for Dynamic Lighting interface + let (dynamic_global, dynamic_kbd, dynamic_lightbar) = { + let global = DynamicLed::find("aura:global") + .map(|g| { + info!("Dynamic Lighting global aggregate detected: aura:global"); + Arc::new(Mutex::new(g)) + }) + .ok(); + let kbd = DynamicLed::find("aura:keyboard") + .map(|k| { + info!("Dynamic Lighting keyboard detected: aura:keyboard"); + Arc::new(Mutex::new(k)) + }) + .ok(); + let lb = DynamicLed::find("aura:lightbar") + .map(|l| { + info!("Dynamic Lighting lightbar detected: aura:lightbar"); + Arc::new(Mutex::new(l)) + }) + .ok(); + if global.is_some() || kbd.is_some() || lb.is_some() { + (global, kbd, lb) + } else { + debug!("Dynamic Lighting not detected; using legacy hidraw fallback"); + (None, None, None) + } + }; + // Load saved mode, colours, brightness, power from disk; apply on reload let mut config = AuraConfig::load_and_update_config(prod_id); config.led_type = aura_type; let aura = Aura { + dynamic_global, + dynamic_kbd, + dynamic_lightbar, hid: device, backlight, config: Arc::new(Mutex::new(config)), diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4340c9385..279225281 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -24,6 +24,7 @@ # Usage - [asusctl](usage/asusctl.md) +- [Dynamic Lighting and legacy fallback](usage/dynamic-lighting.md) # FAQ diff --git a/docs/usage/dynamic-lighting.md b/docs/usage/dynamic-lighting.md new file mode 100644 index 000000000..362204869 --- /dev/null +++ b/docs/usage/dynamic-lighting.md @@ -0,0 +1,55 @@ +# Dynamic Lighting and legacy fallback + +asusd prefers the Linux LED Dynamic Lighting ABI when a device exposes a valid +node below `/sys/class/leds`. A valid node must provide the generic `effect`, +`effect_index`, `zone_type`, and `led_count` attributes. Optional speed, +direction, palette, power-state, and direct-buffer controls are used only when +the node advertises them. + +On supported ROG laptop keyboards, asusd retains the matching `hidraw` output +report as a capability-based fallback when no valid Dynamic Lighting node is +available. The two paths are mutually exclusive for a physical device: +Dynamic Lighting always wins, and hidraw is not used to retry an effect rejected +by the kernel. This preserves operation on released kernels while avoiding two +owners sending commands to the same controller. + +## Topology (`aura_mode`) + +The ASUS-only `aura_mode` attribute selects kernel topology; it is not part of +the generic Dynamic Lighting ABI. + +| Mode | Writable nodes | Notes | +|------|----------------|-------| +| `auto` | same as `split` | Kernel default resolution | +| `split` | `aura:keyboard`, `aura:lightbar` | Independent colours; `aura:global` returns `-EBUSY` | +| `unified` | `aura:global` | Single effect for all zones; split nodes return `-EBUSY` | + +When a chassis lightbar is present, asusd sets `split` during initialization so +keyboard and lightbar are independently controllable. Callers that want one +shared effect should set `unified` explicitly. + +Kernel direct RGB may use HID LampArray (Usage Page `0x59`) when Aura `0xBC` +cannot drive the lightbar independently; firmware animations stay on Aura +`0xb3`. That backend choice is invisible to the sysfs ABI. + +## D-Bus zones + +The D-Bus API still describes historical keyboard and left/right lightbar +subzones. Under Dynamic Lighting those are not advertised +(`supported_basic_zones` is empty). Incoming legacy zone values are accepted +only as aliases: Key1–4 map to the keyboard node, BarLeft/BarRight to the +lightbar, and `None` fans out under split or uses `aura:global` under unified. + +## Other devices + +ROG NVMe enclosure lighting requires the kernel ASUS Aura SCSI Dynamic Lighting +driver. asusd matches an enclosure's block-device ancestry to its exact LED +node and briefly retries while that node is being registered. It never falls +back to the first enclosure. The old public `rog_scsi` SG_IO API was removed +intentionally: vendor commands are kernel-owned, and applications must use the +Dynamic Lighting sysfs ABI. + +The optional generic `frame` attribute is not currently used by asusd. Direct +streaming uses `direct_buffer` and requires exactly `led_count * 3` RGB bytes. +Hardware-specific behavior still depends on the kernel driver reporting correct +topology, LED count, and optional attributes. diff --git a/rog-platform/src/hid_raw.rs b/rog-platform/src/hid_raw.rs index ec9c17373..bf5e679d6 100644 --- a/rog-platform/src/hid_raw.rs +++ b/rog-platform/src/hid_raw.rs @@ -21,6 +21,12 @@ pub struct HidRaw { } impl HidRaw { + /// Check whether a hidraw endpoint descriptor declares an output report ID. + pub fn supports_output_report(endpoint: &Device, report_id: u8) -> bool { + std::fs::read(endpoint.syspath().join("device/report_descriptor")) + .is_ok_and(|descriptor| descriptor_has_output_report(&descriptor, report_id)) + } + pub fn new(id_product: &str) -> Result { let mut enumerator = udev::Enumerator::new().map_err(|err| { warn!("{}", err); @@ -87,7 +93,7 @@ impl HidRaw { file: RefCell::new(OpenOptions::new().write(true).open(dev_node)?), devfs_path: dev_node.to_owned(), prod_id: id_product.to_string_lossy().into(), - _device_bcd: endpoint + _device_bcd: parent .attribute_value("bcdDevice") .unwrap_or_default() .to_string_lossy() @@ -106,12 +112,73 @@ impl HidRaw { /// Write an array of raw bytes to the device using the hidraw interface pub fn write_bytes(&self, message: &[u8]) -> Result<()> { - if let Ok(mut file) = self.file.try_borrow_mut() { - // TODO: re-get the file if error? - file.write_all(message).map_err(|e| { - PlatformError::IoPath(self.devfs_path.to_string_lossy().to_string(), e) - })?; + if message.is_empty() { + return Err(PlatformError::InvalidValue); + } + let mut file = self + .file + .try_borrow_mut() + .map_err(|_| PlatformError::InvalidValue)?; + file.write_all(message) + .map_err(|e| PlatformError::IoPath(self.devfs_path.to_string_lossy().to_string(), e)) + } +} + +fn descriptor_has_output_report(descriptor: &[u8], wanted_id: u8) -> bool { + let mut offset = 0; + let mut report_id = 0; + while let Some(prefix) = descriptor.get(offset).copied() { + if prefix == 0xfe { + let Some(size) = descriptor.get(offset + 1).copied() else { + return false; + }; + offset = match offset.checked_add(3 + usize::from(size)) { + Some(next) => next, + None => return false, + }; + continue; } - Ok(()) + let size = match prefix & 0x03 { + 3 => 4, + value => usize::from(value), + }; + let end = match offset.checked_add(1 + size) { + Some(end) => end, + None => return false, + }; + let Some(data) = descriptor.get(offset + 1..end) else { + return false; + }; + let item_type = (prefix >> 2) & 0x03; + let tag = prefix >> 4; + if item_type == 1 && tag == 8 && size == 1 { + report_id = data[0]; + } else if item_type == 0 && tag == 9 && report_id == wanted_id { + return true; + } + offset = end; + } + false +} + +#[cfg(test)] +mod tests { + use super::descriptor_has_output_report; + + #[test] + fn parses_output_report_id_safely() { + assert!(descriptor_has_output_report( + &[ + 0x85, 0x5d, 0x09, 0x01, 0x91, 0x02 + ], + 0x5d + )); + assert!(!descriptor_has_output_report( + &[ + 0x85, 0x5d, 0x09, 0x01, 0x81, 0x02 + ], + 0x5d + )); + assert!(!descriptor_has_output_report(&[0x85], 0x5d)); } } From e804c8c67144eeb8c2d3b5ee54068ba9f5ae8f26 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Wed, 9 Sep 2026 00:24:58 +0200 Subject: [PATCH 5/7] feat(asusd): discover tuf dynamic lighting on asus::kbd_backlight Update DynamicLed device discovery to support non-aura-prefixed LED class nodes that expose the Dynamic Lighting sysfs ABI (effect_index), such as asus::kbd_backlight registered by asus-wmi on TUF laptops. In DeviceHandle::maybe_laptop_aura, fall back to asus::kbd_backlight if aura:keyboard is absent. This allows TUF RGB laptop keyboards to be driven via the unified Dynamic Lighting sysfs path (effect, speed, palette) with graceful fallback to legacy platform sysfs when Dynamic Lighting is not supported by the kernel. Signed-off-by: Marco Scardovi --- asusd/src/aura_types.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/asusd/src/aura_types.rs b/asusd/src/aura_types.rs index 0c8f01872..46eb7fcbe 100644 --- a/asusd/src/aura_types.rs +++ b/asusd/src/aura_types.rs @@ -176,8 +176,9 @@ impl DeviceHandle { }) .ok(); let kbd = DynamicLed::find("aura:keyboard") + .or_else(|_| DynamicLed::find("asus::kbd_backlight")) .map(|k| { - info!("Dynamic Lighting keyboard detected: aura:keyboard"); + info!("Dynamic Lighting keyboard detected: {}", k.name()); Arc::new(Mutex::new(k)) }) .ok(); From 1671cc7504810f5f3a86b9bddb055df8ea263326 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Sat, 12 Sep 2026 19:13:14 +0200 Subject: [PATCH 6/7] feat(asusd): control slash lighting via sysfs led classdev Drive Slash lighting through the kernel LED classdev sysfs path when asus::slash exists. Do not gate on DMI board lists. Kernel Slash LED has no power_states, so boot/sleep/shutdown/battery/lid D-Bus setters return NotSupported instead of persisting a hardware no-op. Signed-off-by: Marco Scardovi --- Cargo.lock | 1 - asusd/src/aura_manager.rs | 110 +++++----------- asusd/src/aura_slash/mod.rs | 51 ++------ asusd/src/aura_slash/trait_impls.rs | 191 ++++++++-------------------- asusd/src/aura_types.rs | 63 ++------- rog-platform/src/lib.rs | 2 + rog-platform/src/slash_led.rs | 65 ++++++++++ rog-slash/Cargo.toml | 4 +- rog-slash/src/data.rs | 35 +---- 9 files changed, 181 insertions(+), 341 deletions(-) create mode 100644 rog-platform/src/slash_led.rs diff --git a/Cargo.lock b/Cargo.lock index 50993ad42..92398e325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4816,7 +4816,6 @@ dependencies = [ name = "rog_slash" version = "6.4.0" dependencies = [ - "dmi_id", "serde", "thiserror 2.0.20", "zbus", diff --git a/asusd/src/aura_manager.rs b/asusd/src/aura_manager.rs index 7cebf5caf..d982fe70f 100644 --- a/asusd/src/aura_manager.rs +++ b/asusd/src/aura_manager.rs @@ -4,11 +4,12 @@ // - Add it to Zbus server // - If udev sees device removed then remove the zbus path -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use log::{debug, error, info, warn}; use mio::{Events, Interest, Poll, Token}; +use rog_aura::AuraDeviceType; use rog_platform::error::PlatformError; use rog_platform::hid_raw::HidRaw; use tokio::sync::Mutex; @@ -96,7 +97,6 @@ pub struct AsusDevice { pub struct DeviceManager { _dbus_connection: Connection, - _hid_handles: Arc>>>>, } /// Returns true if this hidraw device is a non-Aura interface on the @@ -131,30 +131,9 @@ fn is_non_aura_1ce6_interface(device: &Device) -> bool { } impl DeviceManager { - #[allow(clippy::type_complexity)] - async fn get_or_create_hid_handle( - handles: &Arc>>>>, - endpoint: &Device, - ) -> Result<(Arc>, String), RogError> { - let dev_node = endpoint - .devnode() - .ok_or_else(|| RogError::MissingFunction("hidraw devnode missing".to_string()))?; - let key = dev_node.to_string_lossy().to_string(); - - if let Some(existing) = handles.lock().await.get(&key).cloned() { - return Ok((existing, key)); - } - - let hidraw = HidRaw::from_device(endpoint.clone())?; - let handle = Arc::new(Mutex::new(hidraw)); - handles.lock().await.insert(key.clone(), handle.clone()); - Ok((handle, key)) - } - async fn init_hid_devices( connection: &Connection, device: Device, - handles: Arc>>>>, ) -> Result, RogError> { let mut devices = Vec::new(); if let Some(usb_device) = device.parent_with_subsystem_devtype("usb", "usb_device")? @@ -169,36 +148,22 @@ impl DeviceManager { // So let's see what we have and: // 1. Generate an interface path // 2. Create the device - // Use the top-level endpoint, not the parent - if let Ok((dev, hid_key)) = Self::get_or_create_hid_handle(&handles, &device).await { - debug!("Testing device {usb_id:?}"); - // SLASH DEVICE - if let Ok(dev_type) = - DeviceHandle::new_slash_hid(dev.clone(), usb_id.to_str().unwrap_or_default()) - .await - && let DeviceHandle::Slash(slash) = dev_type.clone() - { - let path = dbus_path_for_dev(&usb_device).unwrap_or(dbus_path_for_slash()); - let ctrl = SlashZbus::new(slash); - if ctrl - .start_tasks(connection, path.clone()) - .await - .map_err(|e| { - error!("Failed to start Slash tasks: {e:?}, not adding this device") - }) - .is_ok() - { - devices.push(AsusDevice { - device: dev_type, - dbus_path: path, - hid_key: Some(hid_key.clone()), - }); - } - } - // AURA LAPTOP DEVICE - if let Ok(dev_type) = - DeviceHandle::maybe_laptop_aura(Some(dev), usb_id.to_str().unwrap_or_default()) - .await + let usb_id_str = usb_id.to_str().unwrap_or_default(); + let aura_type = AuraDeviceType::from(usb_id_str); + if matches!( + aura_type, + AuraDeviceType::LaptopKeyboard2021 + | AuraDeviceType::LaptopKeyboardPre2021 + | AuraDeviceType::LaptopKeyboardTuf + | AuraDeviceType::Ally + ) { + let hid_key = device + .devnode() + .map(|path| path.to_string_lossy().into_owned()); + let hid = HidRaw::from_device(device) + .map(|hid| Arc::new(Mutex::new(hid))) + .ok(); + if let Ok(dev_type) = DeviceHandle::maybe_laptop_aura(hid, usb_id_str).await && let DeviceHandle::Aura(aura) = dev_type.clone() { let path = dbus_path_for_dev(&usb_device).unwrap_or(dbus_path_for_tuf()); @@ -214,22 +179,18 @@ impl DeviceManager { devices.push(AsusDevice { device: dev_type, dbus_path: path, - hid_key: Some(hid_key), + hid_key, }); } } - } else { - warn!("Failed to initialise shared hid handle for {usb_id:?}"); + return Ok(devices); } } Ok(devices) } /// To be called on daemon startup - async fn init_all_hid( - connection: &Connection, - handles: Arc>>>>, - ) -> Result, RogError> { + async fn init_all_hid(connection: &Connection) -> Result, RogError> { // Ensure we only process one hidraw interface per physical USB device. // A USB device can expose multiple HID interfaces (and thus multiple hidraw nodes). // Processing more than one causes duplicate device initialisation which can @@ -266,7 +227,7 @@ impl DeviceManager { } } - devices.append(&mut Self::init_hid_devices(connection, device, handles.clone()).await?); + devices.append(&mut Self::init_hid_devices(connection, device).await?); } Ok(devices) @@ -406,13 +367,10 @@ impl DeviceManager { Ok(devices) } - pub async fn find_all_devices( - connection: &Connection, - handles: Arc>>>>, - ) -> Vec { + pub async fn find_all_devices(connection: &Connection) -> Vec { let mut devices: Vec = Vec::new(); // HID first, always - if let Ok(devs) = &mut Self::init_all_hid(connection, handles.clone()).await { + if let Ok(devs) = &mut Self::init_all_hid(connection).await { devices.append(devs); } // USB after, need to check if HID picked something up and if so, skip it @@ -432,7 +390,7 @@ impl DeviceManager { } if do_slash { - if let Ok(dev_type) = DeviceHandle::new_slash_usb().await { + if let Ok(dev_type) = DeviceHandle::maybe_slash().await { if let DeviceHandle::Slash(slash) = dev_type.clone() { let path = dbus_path_for_slash(); let ctrl = SlashZbus::new(slash); @@ -513,19 +471,16 @@ impl DeviceManager { pub async fn new(connection: Connection) -> Result { let conn_copy = connection.clone(); - let hid_handles = Arc::new(Mutex::new(HashMap::new())); - let devices = Self::find_all_devices(&conn_copy, hid_handles.clone()).await; + let devices = Self::find_all_devices(&conn_copy).await; info!("Found {} valid devices on startup", devices.len()); let devices = Arc::new(Mutex::new(devices)); let manager = Self { _dbus_connection: connection, - _hid_handles: hid_handles.clone(), }; // TODO: The /sysfs/ LEDs don't cause events, so they need to be manually // checked for and added - let hid_handles_thread = hid_handles.clone(); std::thread::spawn(move || { let mut monitor = MonitorBuilder::new()?.listen()?; let mut poll = Poll::new()?; @@ -554,7 +509,6 @@ impl DeviceManager { let devices = devices.clone(); let conn_copy = conn_copy.clone(); - let hid_handles = hid_handles_thread.clone(); rt.block_on(async move { // SCSCI devs if subsys == "block" { @@ -672,11 +626,6 @@ impl DeviceManager { }; info!("AuraManager removed: {path:?}, {res}"); } - // Always drop the shared handle for this node, even if no - // AsusDevice referenced it, so the fd (and minor) is freed. - if hid_handles.lock().await.remove(&removed_node).is_some() { - info!("Dropped hid handle for {removed_node}"); - } } } else if action == "add" && let Some(parent) = @@ -699,10 +648,9 @@ impl DeviceManager { if is_non_aura_1ce6_interface(&evdev) { return Ok(()); } - if let Ok(mut new_devs) = - Self::init_hid_devices(&conn_copy, evdev, hid_handles.clone()) - .await - .map_err(|e| error!("Couldn't add new device: {e:?}")) + if let Ok(mut new_devs) = Self::init_hid_devices(&conn_copy, evdev) + .await + .map_err(|e| error!("Couldn't add new device: {e:?}")) { devices.lock().await.append(&mut new_devs); } diff --git a/asusd/src/aura_slash/mod.rs b/asusd/src/aura_slash/mod.rs index 8996f59e7..b725eb73e 100644 --- a/asusd/src/aura_slash/mod.rs +++ b/asusd/src/aura_slash/mod.rs @@ -1,9 +1,7 @@ use std::sync::Arc; use config::SlashConfig; -use rog_platform::hid_raw::HidRaw; -use rog_platform::usb_raw::USBRaw; -use rog_slash::usb::{slash_pkt_enable, slash_pkt_init, slash_pkt_options, slash_pkt_set_mode}; +use rog_platform::slash_led::SlashLed; use tokio::sync::{Mutex, MutexGuard}; use crate::error::RogError; @@ -13,57 +11,32 @@ pub mod trait_impls; #[derive(Debug, Clone)] pub struct Slash { - hid: Option>>, - usb: Option>>, + led: SlashLed, config: Arc>, } impl Slash { - pub fn new( - hid: Option>>, - usb: Option>>, - config: Arc>, - ) -> Self { - Self { hid, usb, config } + pub fn new(led: SlashLed, config: Arc>) -> Self { + Self { led, config } } - pub async fn lock_config(&self) -> MutexGuard<'_, SlashConfig> { - self.config.lock().await + pub fn led(&self) -> &SlashLed { + &self.led } - pub async fn write_bytes(&self, message: &[u8]) -> Result<(), RogError> { - if let Some(hid) = &self.hid { - hid.lock().await.write_bytes(message)?; - } else if let Some(usb) = &self.usb { - usb.lock().await.write_bytes(message)?; - } - Ok(()) + pub async fn lock_config(&self) -> MutexGuard<'_, SlashConfig> { + self.config.lock().await } /// Initialise the device if required. Locks the internal config so be wary /// of deadlocks. pub async fn do_initialization(&self) -> Result<(), RogError> { - // Don't try to initialise these models as the asus drivers already did let config = self.config.lock().await; - for pkt in &slash_pkt_init(config.slash_type) { - self.write_bytes(pkt).await?; - } - self.write_bytes(&slash_pkt_enable(config.slash_type, config.enabled)) - .await?; - - // Apply config upon initialization - let option_packets = slash_pkt_options( - config.slash_type, - config.enabled, - config.brightness, - config.display_interval, - ); - self.write_bytes(&option_packets).await?; - - let mode_packets = slash_pkt_set_mode(config.slash_type, config.display_mode); - // self.node.write_bytes(&mode_packets[0])?; - self.write_bytes(&mode_packets[1]).await?; + let brightness = if config.enabled { config.brightness } else { 0 }; + self.led.set_brightness(brightness)?; + self.led.set_slash_interval(config.display_interval)?; + self.led.set_slash_mode(&config.display_mode.to_string())?; Ok(()) } } diff --git a/asusd/src/aura_slash/trait_impls.rs b/asusd/src/aura_slash/trait_impls.rs index 70aa1ad36..0ad65ab26 100644 --- a/asusd/src/aura_slash/trait_impls.rs +++ b/asusd/src/aura_slash/trait_impls.rs @@ -1,10 +1,5 @@ use config_traits::StdConfig; use log::{debug, error, warn}; -use rog_slash::usb::{ - slash_pkt_battery_saver, slash_pkt_boot, slash_pkt_enable, slash_pkt_lid_closed, - slash_pkt_low_battery, slash_pkt_options, slash_pkt_save, slash_pkt_set_mode, - slash_pkt_shutdown, slash_pkt_sleep, -}; use rog_slash::{DeviceState, SlashMode}; use zbus::zvariant::OwnedObjectPath; use zbus::{Connection, interface}; @@ -13,6 +8,15 @@ use super::Slash; use crate::Reloadable; use crate::error::RogError; +const SLASH_POWER_UNSUPPORTED: &str = concat!( + "Kernel Slash LED has no power_states; ", + "boot/sleep/shutdown/battery/lid persistence is not supported" +); + +fn slash_power_not_supported() -> zbus::fdo::Error { + zbus::fdo::Error::NotSupported(SLASH_POWER_UNSUPPORTED.into()) +} + #[derive(Clone)] pub struct SlashZbus(Slash); @@ -26,7 +30,6 @@ impl SlashZbus { connection: &Connection, path: OwnedObjectPath, ) -> Result<(), RogError> { - // let task = zbus.clone(); self.reload() .await .unwrap_or_else(|err| warn!("Controller error: {}", err)); @@ -34,9 +37,11 @@ impl SlashZbus { .object_server() .at(path.clone(), self) .await - .map_err(|e| error!("Couldn't add server at path: {path}, {e:?}")) - .ok(); - Ok(()) + .map_err(|e| { + error!("Couldn't add server at path: {path}, {e:?}"); + RogError::from(e) + }) + .map(|_| ()) } } @@ -58,25 +63,11 @@ impl SlashZbus { } else { config.brightness }; - self.0 - .write_bytes(&slash_pkt_enable(config.slash_type, enabled)) - .await - .map_err(|err| { - warn!("ctrl_slash::enable {}", err); - }) - .ok(); - self.0 - .write_bytes(&slash_pkt_options( - config.slash_type, - enabled, - brightness, - config.display_interval, - )) - .await - .map_err(|err| { - warn!("ctrl_slash::set_options {}", err); - }) - .ok(); + + let b = if enabled { brightness } else { 0 }; + if let Err(err) = self.0.led().set_brightness(b) { + warn!("ctrl_slash::set_enabled via sysfs: {err}"); + } config.enabled = enabled; config.brightness = brightness; @@ -95,18 +86,10 @@ impl SlashZbus { async fn set_brightness(&self, brightness: u8) { let mut config = self.0.lock_config().await; let enabled = brightness > 0; - self.0 - .write_bytes(&slash_pkt_options( - config.slash_type, - enabled, - brightness, - config.display_interval, - )) - .await - .map_err(|err| { - warn!("ctrl_slash::set_options {}", err); - }) - .ok(); + + if let Err(err) = self.0.led().set_brightness(brightness) { + warn!("ctrl_slash::set_brightness via sysfs: {err}"); + } config.enabled = enabled; config.brightness = brightness; @@ -123,15 +106,10 @@ impl SlashZbus { #[zbus(property)] async fn set_interval(&self, interval: u8) { let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_options( - config.slash_type, config.enabled, config.brightness, interval, - )) - .await - .map_err(|err| { - warn!("ctrl_slash::set_options {}", err); - }) - .ok(); + + if let Err(err) = self.0.led().set_slash_interval(interval) { + warn!("ctrl_slash::set_interval via sysfs: {err}"); + } config.display_interval = interval; config.write(); @@ -143,7 +121,7 @@ impl SlashZbus { Ok(config.display_mode as u8) } - /// Set interval between slash animations (0-255) + /// Set animation mode #[zbus(property)] async fn set_mode(&self, mode: u8) -> zbus::Result<()> { let mode = SlashMode::try_from(mode).map_err(|err| { @@ -151,12 +129,12 @@ impl SlashZbus { })?; let mut config = self.0.lock_config().await; - let command_packets = slash_pkt_set_mode(config.slash_type, mode); - // self.node.write_bytes(&command_packets[0])?; - self.0.write_bytes(&command_packets[1]).await?; self.0 - .write_bytes(&slash_pkt_save(config.slash_type)) - .await?; + .led() + .set_slash_mode(&mode.to_string()) + .map_err(|err| { + zbus::fdo::Error::Failed(format!("ctrl_slash::set_mode sysfs: {err}")) + })?; config.display_mode = mode; config.write(); @@ -164,7 +142,6 @@ impl SlashZbus { } /// Get the device state as stored by asusd - // #[zbus(property)] async fn device_state(&self) -> DeviceState { let config = self.0.lock_config().await; DeviceState::from(&*config) @@ -177,14 +154,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_on_boot(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_boot(config.slash_type, enable)) - .await?; - config.show_on_boot = enable; - config.write(); - Ok(()) + async fn set_show_on_boot(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } #[zbus(property)] @@ -194,14 +165,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_on_sleep(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_sleep(config.slash_type, enable)) - .await?; - config.show_on_sleep = enable; - config.write(); - Ok(()) + async fn set_show_on_sleep(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } #[zbus(property)] @@ -211,14 +176,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_on_shutdown(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_shutdown(config.slash_type, enable)) - .await?; - config.show_on_shutdown = enable; - config.write(); - Ok(()) + async fn set_show_on_shutdown(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } #[zbus(property)] @@ -228,14 +187,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_on_battery(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_battery_saver(config.slash_type, enable)) - .await?; - config.show_on_battery = enable; - config.write(); - Ok(()) + async fn set_show_on_battery(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } #[zbus(property)] @@ -245,14 +198,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_battery_warning(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_low_battery(config.slash_type, enable)) - .await?; - config.show_battery_warning = enable; - config.write(); - Ok(()) + async fn set_show_battery_warning(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } #[zbus(property)] @@ -262,17 +209,8 @@ impl SlashZbus { } #[zbus(property)] - async fn set_show_on_lid_closed(&self, enable: bool) -> zbus::Result<()> { - let mut config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_lid_closed(config.slash_type, enable)) - .await?; - self.0 - .write_bytes(&slash_pkt_save(config.slash_type)) - .await?; - config.show_on_lid_closed = enable; - config.write(); - Ok(()) + async fn set_show_on_lid_closed(&self, _enable: bool) -> zbus::Result<()> { + Err(slash_power_not_supported().into()) } } @@ -280,40 +218,13 @@ impl Reloadable for SlashZbus { async fn reload(&mut self) -> Result<(), RogError> { debug!("reloading slash settings"); let config = self.0.lock_config().await; - self.0 - .write_bytes(&slash_pkt_options( - config.slash_type, - config.enabled, - config.brightness, - config.display_interval, - )) - .await - .map_err(|err| { - warn!("set_options {}", err); - }) - .ok(); - - macro_rules! write_bytes_with_warning { - ($packet_fn:expr, $cfg:ident, $warn_msg:expr) => { - self.0 - .write_bytes(&$packet_fn(config.slash_type, config.$cfg)) - .await - .map_err(|err| { - warn!("{} {}", $warn_msg, err); - }) - .ok(); - }; - } - write_bytes_with_warning!(slash_pkt_boot, show_on_boot, "show_on_boot"); - write_bytes_with_warning!(slash_pkt_sleep, show_on_sleep, "show_on_sleep"); - write_bytes_with_warning!(slash_pkt_shutdown, show_on_shutdown, "show_on_shutdown"); - write_bytes_with_warning!(slash_pkt_battery_saver, show_on_battery, "show_on_battery"); - write_bytes_with_warning!( - slash_pkt_low_battery, - show_battery_warning, - "show_battery_warning" - ); + let brightness = if config.enabled { config.brightness } else { 0 }; + self.0.led().set_brightness(brightness)?; + self.0.led().set_slash_interval(config.display_interval)?; + self.0 + .led() + .set_slash_mode(&config.display_mode.to_string())?; Ok(()) } diff --git a/asusd/src/aura_types.rs b/asusd/src/aura_types.rs index 46eb7fcbe..0e446a092 100644 --- a/asusd/src/aura_types.rs +++ b/asusd/src/aura_types.rs @@ -1,18 +1,18 @@ use std::sync::Arc; use config_traits::{StdConfig, StdConfigLoad}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use rog_anime::AnimeType; use rog_anime::error::AnimeError; use rog_anime::usb::get_anime_type; use rog_aura::AuraDeviceType; use rog_platform::DynamicLed; +use rog_platform::SlashLed; use rog_platform::hid_raw::HidRaw; use rog_platform::keyboard_led::KeyboardBacklight; use rog_platform::usb_raw::USBRaw; use rog_scsi::{ScsiType, open_device}; use rog_slash::SlashType; -use rog_slash::error::SlashError; use tokio::sync::Mutex; use crate::aura_anime::AniMe; @@ -28,7 +28,6 @@ use crate::error::RogError; pub enum _DeviceHandle { /// The AniMe devices require USBRaw as they are not HID devices Usb(USBRaw), - HidRaw(HidRaw), LedClass(KeyboardBacklight), /// TODO MulticolourLed, @@ -48,57 +47,23 @@ pub enum DeviceHandle { } impl DeviceHandle { - /// Try Slash HID. If one exists it is initialised and returned. - pub async fn new_slash_hid( - device: Arc>, - prod_id: &str, - ) -> Result { - debug!("Testing for HIDRAW Slash"); - let slash_type = SlashType::from_dmi(); - if matches!(slash_type, SlashType::Unsupported) - || slash_type - .prod_id_str() - .to_lowercase() - .trim_start_matches("0x") - != prod_id - { - log::info!("Unknown or invalid slash: {prod_id:?}, skipping"); - return Err(RogError::NotFound("No slash device".to_string())); - } - info!("Found slash type {slash_type:?}: {prod_id}"); - + /// Try Slash sysfs LED. Presence of `asus::slash` is the capability gate; + /// board DMI lists are not used. + pub async fn maybe_slash() -> Result { + debug!("Testing for Slash"); + let led = SlashLed::new().map_err(|e| { + warn!("No Slash sysfs LED found: {e}"); + RogError::NotFound("No slash device found".to_string()) + })?; + + info!("Found Slash sysfs LED at {:?}", led.path()); let mut config = SlashConfig::new().load(); - config.slash_type = slash_type; - let slash = Slash::new(Some(device), None, Arc::new(Mutex::new(config))); + config.slash_type = SlashType::Unsupported; + let slash = Slash::new(led, Arc::new(Mutex::new(config))); slash.do_initialization().await?; Ok(Self::Slash(slash)) } - /// Try Slash USB. If one exists it is initialised and returned. - pub async fn new_slash_usb() -> Result { - debug!("Testing for USB Slash"); - let slash_type = SlashType::from_dmi(); - if matches!(slash_type, SlashType::Unsupported) { - return Err(RogError::Slash(SlashError::NoDevice)); - } - - if let Ok(usb) = USBRaw::new(slash_type.prod_id()) { - info!("Found Slash USB {slash_type:?}"); - - let mut config = SlashConfig::new().load(); - config.slash_type = slash_type; - let slash = Slash::new( - None, - Some(Arc::new(Mutex::new(usb))), - Arc::new(Mutex::new(config)), - ); - slash.do_initialization().await?; - Ok(Self::Slash(slash)) - } else { - Err(RogError::NotFound("No slash device found".to_string())) - } - } - pub async fn maybe_anime_usb() -> Result { debug!("Testing for USB AniMe"); let anime_type = get_anime_type(); diff --git a/rog-platform/src/lib.rs b/rog-platform/src/lib.rs index 7a4650cd4..8014a3358 100644 --- a/rog-platform/src/lib.rs +++ b/rog-platform/src/lib.rs @@ -13,6 +13,7 @@ pub mod keyboard_led; pub(crate) mod macros; pub mod platform; pub mod power; +pub mod slash_led; pub mod usb_raw; use std::path::Path; @@ -21,6 +22,7 @@ pub use dynamic_led::DynamicLed; use error::{PlatformError, Result}; use log::warn; use platform::PlatformProfile; +pub use slash_led::SlashLed; use udev::Device; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/rog-platform/src/slash_led.rs b/rog-platform/src/slash_led.rs new file mode 100644 index 000000000..98fb8aeba --- /dev/null +++ b/rog-platform/src/slash_led.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use log::{info, warn}; + +use crate::error::{PlatformError, Result}; +use crate::{attr_num, attr_string, to_device}; + +#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Clone)] +pub struct SlashLed { + path: PathBuf, +} + +impl SlashLed { + attr_num!("brightness", path, u8); + attr_num!("max_brightness", path, u8); + + attr_string!("slash_mode", path); + attr_string!("slash_mode_index", path); + attr_num!("slash_interval", path, u8); + + pub fn new() -> Result { + let std_path = Path::new("/sys/class/leds/asus::slash"); + if std_path.exists() { + info!("Found Slash LED at {:?}", std_path); + return Ok(Self { + path: std_path.to_owned(), + }); + } + + let mut enumerator = udev::Enumerator::new().map_err(|err| { + warn!("{}", err); + PlatformError::Udev("enumerator failed".into(), err) + })?; + + enumerator.match_subsystem("leds").map_err(|err| { + warn!("{}", err); + PlatformError::Udev("match_subsystem failed".into(), err) + })?; + + for device in enumerator.scan_devices().map_err(|err| { + warn!("{}", err); + PlatformError::Udev("scan_devices failed".into(), err) + })? { + let sys = device.sysname().to_string_lossy(); + if sys.contains("slash") { + info!("Found Slash LED controls at {:?}", device.sysname()); + return Ok(Self { + path: device.syspath().to_owned(), + }); + } + } + + Err(PlatformError::MissingFunction( + "SlashLed:new(), asus::slash not found".into(), + )) + } + + pub fn is_available() -> bool { + Self::new().is_ok() + } + + pub fn path(&self) -> &Path { + &self.path + } +} diff --git a/rog-slash/Cargo.toml b/rog-slash/Cargo.toml index 1b3dfe27b..daf39b471 100644 --- a/rog-slash/Cargo.toml +++ b/rog-slash/Cargo.toml @@ -13,9 +13,8 @@ keywords = ["ROG", "ASUS", "AniMe", "Slash"] exclude = ["data"] [features] -default = ["dbus", "detect"] +default = ["dbus"] dbus = ["zbus"] -detect = ["dmi_id"] [lib] name = "rog_slash" @@ -24,5 +23,4 @@ path = "src/lib.rs" [dependencies] serde.workspace = true zbus = { workspace = true, optional = true } -dmi_id = { path = "../dmi-id", optional = true } thiserror.workspace = true diff --git a/rog-slash/src/data.rs b/rog-slash/src/data.rs index 8cd0a50e6..a2319aca3 100644 --- a/rog-slash/src/data.rs +++ b/rog-slash/src/data.rs @@ -1,7 +1,6 @@ use std::fmt::Display; use std::str::FromStr; -use dmi_id::DMIID; use serde::{Deserialize, Serialize}; #[cfg(feature = "dbus")] use zbus::zvariant::Type; @@ -56,33 +55,13 @@ impl SlashType { } } - pub fn from_dmi() -> Self { - let board_name = DMIID::new().unwrap_or_default().board_name.to_uppercase(); - if board_name.contains("G614F") { - SlashType::G614_2025 - } else if [ - "GA403W", "GA403UH", "GA403UM", "GA403UP", "GA403GM", - ] - .iter() - .any(|s| board_name.contains(s)) - { - SlashType::GA403_2025 - } else if board_name.contains("GA403") { - SlashType::GA403_2024 - } else if board_name.contains("GA605K") { - SlashType::GA605_2025 - } else if board_name.contains("GA605") { - SlashType::GA605_2024 - } else if board_name.contains("GU405") { - SlashType::GU405_2026 - } else if board_name.contains("GU606") { - SlashType::GU606_2026 - } else if board_name.contains("GU605C") { - SlashType::GU605_2025 - } else if board_name.contains("GU605") { - SlashType::GU605_2024 - } else { - SlashType::Unsupported + /// Select hidraw packet layout from the USB product id. + /// Report 0x5e vs 0x5d is a device capability, not a DMI board name. + pub fn from_usb_product(id: &str) -> Self { + match id.trim_start_matches("0x").to_ascii_uppercase().as_str() { + PROD_ID1_STR => SlashType::GA403_2024, + PROD_ID2_STR => SlashType::GA403_2025, + _ => SlashType::Unsupported, } } } From cbbe60ace2f1b1d313d132134b019f14232b4ea9 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Sat, 12 Sep 2026 19:13:15 +0200 Subject: [PATCH 7/7] feat(asusd): control scsi lighting via dynamic lighting sysfs Match ROG NVMe enclosures to their exact Aura SCSI Dynamic Lighting nodes, retry briefly during registration, and remove the public rog-scsi SG_IO path so vendor commands stay kernel-owned. Also finish hotplug identity, stale-object cleanup, and power_states MissingFunction handling on the shared Aura device manager/types. Signed-off-by: Marco Scardovi --- Cargo.lock | 1 - Cargo.toml | 1 - asusd/src/aura_manager.rs | 140 ++++++++---------- asusd/src/aura_scsi/config.rs | 36 ----- asusd/src/aura_scsi/mod.rs | 99 +++++++++++-- asusd/src/aura_scsi/trait_impls.rs | 8 +- asusd/src/aura_types.rs | 83 +++++++---- rog-platform/src/lib.rs | 2 + rog-platform/src/scsi_led.rs | 198 ++++++++++++++++++++++++++ rog-scsi/Cargo.toml | 1 - rog-scsi/src/builtin_modes.rs | 38 ----- rog-scsi/src/lib.rs | 7 - rog-scsi/src/scsi.rs | 78 ---------- rog-scsi/src/sg.rs | 219 ----------------------------- 14 files changed, 406 insertions(+), 505 deletions(-) create mode 100644 rog-platform/src/scsi_led.rs delete mode 100644 rog-scsi/src/scsi.rs delete mode 100644 rog-scsi/src/sg.rs diff --git a/Cargo.lock b/Cargo.lock index 92398e325..81e08ba1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4795,7 +4795,6 @@ dependencies = [ name = "rog_scsi" version = "6.4.0" dependencies = [ - "libc", "ron", "serde", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 1de9691a4..8f727797e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,6 @@ glam = { version = "^0.33.5", features = ["serde"] } image = "=0.25.10" inotify = "^0.11.5" ksni = { version = "^0.3.6", default-features = false, features = ["async-io"] } -libc = "^0.2.189" log = "^0.4.33" logind-zbus = { version = "^5.3.2" } mio = "^1.2.2" diff --git a/asusd/src/aura_manager.rs b/asusd/src/aura_manager.rs index d982fe70f..4096703fa 100644 --- a/asusd/src/aura_manager.rs +++ b/asusd/src/aura_manager.rs @@ -32,26 +32,26 @@ const MOD_NAME: &str = "aura"; pub fn filename_partial(parent: &Device) -> Option { if let Some(id_product) = parent.attribute_value("idProduct") { let id_product = id_product.to_string_lossy(); - let mut path = if let Some(devnum) = parent.attribute_value("devnum") { - let devnum = devnum.to_string_lossy(); - if let Some(devpath) = parent.attribute_value("devpath") { - let devpath = devpath.to_string_lossy(); - format!("{id_product}_{devnum}_{devpath}") - } else { - format!("{id_product}_{devnum}") - } + let identity = if let Some(serial) = parent.attribute_value("serial") { + serial.to_string_lossy() + } else if let Some(devpath) = parent.attribute_value("devpath") { + devpath.to_string_lossy() } else { - format!("{id_product}") + parent.sysname().to_string_lossy() }; - if path.contains('.') { - warn!("dbus path for {id_product} contains `.`, removing"); - path.replace('.', "").clone_into(&mut path); - } + let path = sanitize_path_component(&format!("{id_product}_{identity}")); return Some(ObjectPath::from_str_unchecked(&path).into()); } None } +fn sanitize_path_component(value: &str) -> String { + value + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + fn dbus_path_for_dev(parent: &Device) -> Option { if let Some(filename) = filename_partial(parent) { return Some( @@ -160,9 +160,13 @@ impl DeviceManager { let hid_key = device .devnode() .map(|path| path.to_string_lossy().into_owned()); - let hid = HidRaw::from_device(device) - .map(|hid| Arc::new(Mutex::new(hid))) - .ok(); + let hid = if HidRaw::supports_output_report(&device, 0x5d) { + HidRaw::from_device(device) + .map(|hid| Arc::new(Mutex::new(hid))) + .ok() + } else { + None + }; if let Ok(dev_type) = DeviceHandle::maybe_laptop_aura(hid, usb_id_str).await && let DeviceHandle::Aura(aura) = dev_type.clone() { @@ -192,9 +196,10 @@ impl DeviceManager { /// To be called on daemon startup async fn init_all_hid(connection: &Connection) -> Result, RogError> { // Ensure we only process one hidraw interface per physical USB device. - // A USB device can expose multiple HID interfaces (and thus multiple hidraw nodes). - // Processing more than one causes duplicate device initialisation which can - // interfere with the kernel's own HID driver and trigger a USB reset loop. + // A USB device can expose multiple HID interfaces (and thus multiple hidraw + // nodes). Processing more than one causes duplicate device + // initialisation which can interfere with the kernel's own HID driver + // and trigger a USB reset loop. let mut seen_usb_parents: HashSet = HashSet::new(); let mut devices: Vec = Vec::new(); @@ -218,44 +223,29 @@ impl DeviceManager { continue; } - if let Ok(Some(usb_parent)) = device.parent_with_subsystem_devtype("usb", "usb_device") + let parent_path = if let Ok(Some(usb_parent)) = + device.parent_with_subsystem_devtype("usb", "usb_device") { let parent_path = usb_parent.syspath().to_string_lossy().to_string(); - if !seen_usb_parents.insert(parent_path) { + if seen_usb_parents.contains(&parent_path) { debug!("Skipping duplicate ASUS hidraw for USB parent already processed"); continue; } - } - - devices.append(&mut Self::init_hid_devices(connection, device).await?); - } - - Ok(devices) - } + Some(parent_path) + } else { + None + }; - /// Resolve the `/dev/sgN` (scsi_generic) node backing a block device. - /// - /// Walks up from the block device to its owning scsi_device and reads the - /// `scsi_generic/sgN` child. Works for whole-disk (`/dev/sda`) and - /// partition (`/dev/sda1`) nodes alike, since the scsi_device is a common - /// ancestor. Returns None if no sg node exists (e.g. the `sg` module is - /// not loaded). - fn sg_node_for_block(device: &Device) -> Option { - let mut current = device.parent(); - while let Some(d) = current { - if let Ok(entries) = std::fs::read_dir(d.syspath().join("scsi_generic")) { - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - let node = format!("/dev/{name}"); - if std::path::Path::new(&node).exists() { - return Some(node); - } - } + let mut found = Self::init_hid_devices(connection, device).await?; + if !found.is_empty() { + if let Some(parent_path) = parent_path { + seen_usb_parents.insert(parent_path); } + devices.append(&mut found); } - current = d.parent(); } - None + + Ok(devices) } async fn init_scsi( @@ -272,40 +262,9 @@ impl DeviceManager { .property_value("ID_MODEL_ID") .unwrap_or_default() .to_string_lossy(); - // SG_IO with vendor commands on the block node (/dev/sdX) - // requires CAP_SYS_RAWIO, which the hardened asusd unit drops - // (every ioctl EPERMs and is silently swallowed by write_effect). - // The scsi_generic /dev/sgN node gates access at open() via - // file permissions instead, so it works with no capabilities, - // the same path sg3_utils / OpenRGB use. - // - // On hotplug the sg node can appear just after the block node, - // so retry briefly before falling back to the block device - // (which would EPERM). At startup the node already exists, so - // the first attempt succeeds with no delay. - let mut sg_node = None; - for attempt in 0..8u8 { - if let Some(sg) = Self::sg_node_for_block(device) { - sg_node = Some(sg); - break; - } - if attempt < 7 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - let dev_str = match sg_node { - Some(sg) => Some(sg), - None => { - warn!( - "No /dev/sgN for SCSI device after retries; falling back to block \ - node {:?} (SG_IO will EPERM unless asusd has CAP_SYS_RAWIO)", - dev_node - ); - dev_node.as_os_str().to_str().map(|s| s.to_string()) - } - }; - if let Some(dev_str) = dev_str - && let Ok(dev_type) = DeviceHandle::maybe_scsi(&dev_str, &prod_id).await + + let dev_str = dev_node.to_string_lossy(); + if let Ok(dev_type) = DeviceHandle::maybe_scsi(&dev_str, &prod_id).await && let DeviceHandle::Scsi(scsi) = dev_type.clone() { let ctrl = ScsiZbus::new(scsi); @@ -325,6 +284,7 @@ impl DeviceManager { } } } + None } @@ -548,6 +508,12 @@ impl DeviceManager { if let Some(serial) = evdev.property_value("ID_SERIAL_SHORT") { let serial = serial.to_string_lossy().to_string(); let path = dbus_path_for_scsi(&serial); + if devices.lock().await.iter().any(|d| d.dbus_path == path) { + debug!( + "SCSI hotplug add: device {path:?} already registered" + ); + return Ok(()); + } if let Some(new_devs) = Self::init_scsi(&conn_copy, &evdev, path).await { @@ -669,3 +635,13 @@ impl DeviceManager { Ok(manager) } } + +#[cfg(test)] +mod tests { + use super::sanitize_path_component; + + #[test] + fn physical_identity_is_a_valid_stable_object_component() { + assert_eq!(sanitize_path_component("19b6_1-3.2:1.0"), "19b6_1_3_2_1_0"); + } +} diff --git a/asusd/src/aura_scsi/config.rs b/asusd/src/aura_scsi/config.rs index 5cf775724..4d9ce1d4f 100644 --- a/asusd/src/aura_scsi/config.rs +++ b/asusd/src/aura_scsi/config.rs @@ -56,42 +56,6 @@ impl Default for ScsiConfig { AuraMode::RainbowWave, AuraEffect::default_with_mode(AuraMode::RainbowWave), ), - ( - AuraMode::RainbowCycleBreathe, - AuraEffect::default_with_mode(AuraMode::RainbowCycleBreathe), - ), - ( - AuraMode::ChaseFade, - AuraEffect::default_with_mode(AuraMode::ChaseFade), - ), - ( - AuraMode::RainbowCycleChaseFade, - AuraEffect::default_with_mode(AuraMode::RainbowCycleChaseFade), - ), - ( - AuraMode::Chase, - AuraEffect::default_with_mode(AuraMode::Chase), - ), - ( - AuraMode::RainbowCycleChase, - AuraEffect::default_with_mode(AuraMode::RainbowCycleChase), - ), - ( - AuraMode::RainbowCycleWave, - AuraEffect::default_with_mode(AuraMode::RainbowCycleWave), - ), - ( - AuraMode::RainbowPulseChase, - AuraEffect::default_with_mode(AuraMode::RainbowPulseChase), - ), - ( - AuraMode::RandomFlicker, - AuraEffect::default_with_mode(AuraMode::RandomFlicker), - ), - ( - AuraMode::DoubleFade, - AuraEffect::default_with_mode(AuraMode::DoubleFade), - ), ]), } } diff --git a/asusd/src/aura_scsi/mod.rs b/asusd/src/aura_scsi/mod.rs index 5e77ae47c..afbb65283 100644 --- a/asusd/src/aura_scsi/mod.rs +++ b/asusd/src/aura_scsi/mod.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use config::ScsiConfig; -use rog_scsi::{AuraEffect, Device, Task}; +use rog_platform::ScsiLed; +use rog_scsi::{AuraEffect, AuraMode, Direction}; use tokio::sync::{Mutex, MutexGuard}; use crate::error::RogError; @@ -11,13 +12,17 @@ pub mod trait_impls; #[derive(Clone)] pub struct ScsiAura { - device: Arc>, - config: Arc>, + pub led: ScsiLed, + pub config: Arc>, } impl ScsiAura { - pub fn new(device: Arc>, config: Arc>) -> Self { - Self { device, config } + pub fn new(led: ScsiLed, config: Arc>) -> Self { + Self { led, config } + } + + pub fn led(&self) -> &ScsiLed { + &self.led } pub async fn lock_config(&self) -> MutexGuard<'_, ScsiConfig> { @@ -25,14 +30,84 @@ impl ScsiAura { } pub async fn write_effect(&self, effect: &AuraEffect) -> Result<(), RogError> { - let mut tasks: Vec = effect.into(); - for task in &mut tasks { - // Surface the ioctl errno instead of dropping it — an EPERM/EIO - // here was previously invisible, so asusd reported success while - // no SCSI traffic ever reached the device. - if let Err(e) = self.device.lock().await.perform(task) { - log::warn!("SCSI perform failed: {e}"); + Self::write_kernel_effect(&self.led, effect) + } + + fn write_kernel_effect(led: &ScsiLed, effect: &AuraEffect) -> Result<(), RogError> { + match effect.mode { + AuraMode::Off => { + led.set_effect("off").map_err(RogError::Platform)?; + } + AuraMode::Static => { + let colors = [ + (effect.colour1.r, effect.colour1.g, effect.colour1.b), + (effect.colour2.r, effect.colour2.g, effect.colour2.b), + (effect.colour3.r, effect.colour3.g, effect.colour3.b), + (effect.colour4.r, effect.colour4.g, effect.colour4.b), + ]; + if led.dynamic().has_effects_palette() { + led.set_palette_colors(&colors) + .map_err(RogError::Platform)?; + } + led.set_effect("static").map_err(RogError::Platform)?; + } + AuraMode::Breathe => { + let colors = [ + (effect.colour1.r, effect.colour1.g, effect.colour1.b), + (effect.colour2.r, effect.colour2.g, effect.colour2.b), + (effect.colour3.r, effect.colour3.g, effect.colour3.b), + (effect.colour4.r, effect.colour4.g, effect.colour4.b), + ]; + if led.dynamic().has_effects_palette() { + led.set_palette_colors(&colors) + .map_err(RogError::Platform)?; + } + if led.dynamic().has_speed() { + led.set_speed(effect.speed as u32) + .map_err(RogError::Platform)?; + } + led.set_effect("breathing").map_err(RogError::Platform)?; + } + AuraMode::Flashing => { + let colors = [ + (effect.colour1.r, effect.colour1.g, effect.colour1.b), + (effect.colour2.r, effect.colour2.g, effect.colour2.b), + (effect.colour3.r, effect.colour3.g, effect.colour3.b), + (effect.colour4.r, effect.colour4.g, effect.colour4.b), + ]; + if led.dynamic().has_effects_palette() { + led.set_palette_colors(&colors) + .map_err(RogError::Platform)?; + } + if led.dynamic().has_speed() { + led.set_speed(effect.speed as u32) + .map_err(RogError::Platform)?; + } + led.set_effect("strobe").map_err(RogError::Platform)?; + } + AuraMode::RainbowCycle => { + if led.dynamic().has_speed() { + led.set_speed(effect.speed as u32) + .map_err(RogError::Platform)?; + } + led.set_effect("spectrum_cycle") + .map_err(RogError::Platform)?; + } + AuraMode::RainbowWave => { + if led.dynamic().has_speed() { + led.set_speed(effect.speed as u32) + .map_err(RogError::Platform)?; + } + let dir = match effect.direction { + Direction::Forward => "right", + Direction::Reverse => "left", + }; + if led.dynamic().has_direction() { + led.set_direction(dir).map_err(RogError::Platform)?; + } + led.set_effect("rainbow").map_err(RogError::Platform)?; } + _ => return Err(rog_platform::error::PlatformError::NotSupported.into()), } Ok(()) } diff --git a/asusd/src/aura_scsi/trait_impls.rs b/asusd/src/aura_scsi/trait_impls.rs index 442a00143..96fd10764 100644 --- a/asusd/src/aura_scsi/trait_impls.rs +++ b/asusd/src/aura_scsi/trait_impls.rs @@ -28,9 +28,11 @@ impl ScsiZbus { .object_server() .at(path.clone(), self) .await - .map_err(|e| error!("Couldn't add server at path: {path}, {e:?}")) - .ok(); - Ok(()) + .map_err(|e| { + error!("Couldn't add server at path: {path}, {e:?}"); + RogError::from(e) + }) + .map(|_| ()) } } diff --git a/asusd/src/aura_types.rs b/asusd/src/aura_types.rs index 0e446a092..37db642ca 100644 --- a/asusd/src/aura_types.rs +++ b/asusd/src/aura_types.rs @@ -6,12 +6,11 @@ use rog_anime::AnimeType; use rog_anime::error::AnimeError; use rog_anime::usb::get_anime_type; use rog_aura::AuraDeviceType; -use rog_platform::DynamicLed; -use rog_platform::SlashLed; use rog_platform::hid_raw::HidRaw; use rog_platform::keyboard_led::KeyboardBacklight; use rog_platform::usb_raw::USBRaw; -use rog_scsi::{ScsiType, open_device}; +use rog_platform::{DynamicLed, ScsiLed, SlashLed}; +use rog_scsi::ScsiType; use rog_slash::SlashType; use tokio::sync::Mutex; @@ -25,15 +24,6 @@ use crate::aura_slash::Slash; use crate::aura_slash::config::SlashConfig; use crate::error::RogError; -pub enum _DeviceHandle { - /// The AniMe devices require USBRaw as they are not HID devices - Usb(USBRaw), - LedClass(KeyboardBacklight), - /// TODO - MulticolourLed, - None, -} - #[derive(Clone)] pub enum DeviceHandle { Aura(Aura), @@ -91,24 +81,51 @@ impl DeviceHandle { } pub async fn maybe_scsi(dev_node: &str, prod_id: &str) -> Result { - debug!("Testing for SCSI"); - let prod_id = ScsiType::from(prod_id); - if prod_id == ScsiType::Unsupported { - log::info!("Unknown or invalid SCSI: {prod_id:?}, skipping"); + let scsi_type = ScsiType::from(prod_id); + if scsi_type == ScsiType::Unsupported { + log::info!("Unknown or invalid SCSI: {scsi_type:?}, skipping"); return Err(RogError::NotFound("No SCSI device".to_string())); } - info!("Found SCSI device {prod_id:?} on {dev_node}"); + + let mut last_error = None; + let mut led = None; + for _ in 0..20 { + match ScsiLed::find_for_dev(dev_node) { + Ok(found) => { + led = Some(found); + break; + } + Err(err @ rog_platform::error::PlatformError::MissingFunction(_)) => { + last_error = Some(err); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Err(err) => return Err(err.into()), + } + } + let led = led.ok_or_else(|| { + let err = last_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "unknown discovery error".into()); + log::warn!("No exact SCSI Dynamic Lighting device found for {dev_node}: {err}"); + RogError::NotFound(format!( + "Dynamic Lighting registration timed out for {dev_node}" + )) + })?; + + info!( + "Found SCSI Dynamic Lighting device {scsi_type:?} on {:?}", + led.path() + ); let mut config = ScsiConfig::new().load(); config.dev_type = AuraDeviceType::ScsiExtDisk; - let dev = Arc::new(Mutex::new(open_device(dev_node)?)); - let scsi = ScsiAura::new(dev, Arc::new(Mutex::new(config))); + let scsi = ScsiAura::new(led, Arc::new(Mutex::new(config))); scsi.do_initialization().await?; Ok(Self::Scsi(scsi)) } pub async fn maybe_laptop_aura( - device: Option>>, + hid: Option>>, prod_id: &str, ) -> Result { debug!("Testing for laptop aura"); @@ -153,22 +170,34 @@ impl DeviceHandle { Arc::new(Mutex::new(l)) }) .ok(); - if global.is_some() || kbd.is_some() || lb.is_some() { - (global, kbd, lb) - } else { - debug!("Dynamic Lighting not detected; using legacy hidraw fallback"); - (None, None, None) - } + (global, kbd, lb) }; + let dynamic_available = + dynamic_global.is_some() || dynamic_kbd.is_some() || dynamic_lightbar.is_some(); + let fallback_available = if matches!(aura_type, AuraDeviceType::LaptopKeyboardTuf) { + backlight.is_some() + } else { + hid.is_some() + }; + if !dynamic_available && !fallback_available { + debug!("Neither valid Dynamic Lighting nor device-specific fallback detected"); + return Err(RogError::NotFound( + "No Dynamic Lighting or legacy Aura control path found".to_string(), + )); + } + // Load saved mode, colours, brightness, power from disk; apply on reload let mut config = AuraConfig::load_and_update_config(prod_id); config.led_type = aura_type; + let use_hid = !dynamic_available; let aura = Aura { dynamic_global, dynamic_kbd, dynamic_lightbar, - hid: device, + // One device has exactly one owner: valid Dynamic Lighting nodes win, + // otherwise retain the matching hidraw handle for released kernels. + hid: use_hid.then_some(hid).flatten(), backlight, config: Arc::new(Mutex::new(config)), }; diff --git a/rog-platform/src/lib.rs b/rog-platform/src/lib.rs index 8014a3358..f37e3418d 100644 --- a/rog-platform/src/lib.rs +++ b/rog-platform/src/lib.rs @@ -13,6 +13,7 @@ pub mod keyboard_led; pub(crate) mod macros; pub mod platform; pub mod power; +pub mod scsi_led; pub mod slash_led; pub mod usb_raw; @@ -22,6 +23,7 @@ pub use dynamic_led::DynamicLed; use error::{PlatformError, Result}; use log::warn; use platform::PlatformProfile; +pub use scsi_led::ScsiLed; pub use slash_led::SlashLed; use udev::Device; diff --git a/rog-platform/src/scsi_led.rs b/rog-platform/src/scsi_led.rs new file mode 100644 index 000000000..7285f728d --- /dev/null +++ b/rog-platform/src/scsi_led.rs @@ -0,0 +1,198 @@ +use std::path::Path; + +use log::{info, warn}; + +use crate::dynamic_led::DynamicLed; +use crate::error::{PlatformError, Result}; + +/// Generic control interface for ASUS Aura SCSI-attached lighting devices +/// backed by the kernel `leds-asus-aura-scsi` Dynamic Lighting driver. +#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)] +pub struct ScsiLed { + dynamic: DynamicLed, +} + +impl ScsiLed { + /// Discover the first available ASUS SCSI dynamic lighting LED node. + pub fn new() -> Result { + let mut enumerator = udev::Enumerator::new().map_err(|err| { + warn!("ScsiLed udev enumerator failed: {err}"); + PlatformError::Udev("enumerator failed".into(), err) + })?; + enumerator.match_subsystem("leds").map_err(|err| { + warn!("ScsiLed match_subsystem failed: {err}"); + PlatformError::Udev("match_subsystem failed".into(), err) + })?; + + for device in enumerator.scan_devices().map_err(|err| { + warn!("ScsiLed scan_devices failed: {err}"); + PlatformError::Udev("scan_devices failed".into(), err) + })? { + let sysname = device.sysname().to_string_lossy(); + if sysname.contains("asus-scsi") + || sysname.contains("asus-aura-scsi") + || sysname.contains("asus-arion") + { + info!( + "Found ASUS SCSI Dynamic Lighting LED device at {:?}", + sysname + ); + let dynamic = DynamicLed::find(&sysname)?; + return Ok(Self { dynamic }); + } + } + + Err(PlatformError::MissingFunction( + "ScsiLed::new(): no asus scsi dynamic LED device found".into(), + )) + } + + /// Find the LED whose sysfs ancestry belongs to this exact SCSI device. + pub fn find_for_block(device: &udev::Device) -> Result { + let mut current = device.parent(); + let mut scsi_path = None; + while let Some(d) = current { + if let Some(sub) = d.subsystem() + && sub == "scsi" + { + let s = d.sysname().to_string_lossy(); + if s.contains(':') { + scsi_path = Some(d.syspath().to_path_buf()); + break; + } + } + current = d.parent(); + } + + let scsi_path = scsi_path.ok_or_else(|| { + PlatformError::MissingFunction(format!( + "No SCSI parent found for {}", + device.syspath().display() + )) + })?; + let mut enumerator = udev::Enumerator::new() + .map_err(|err| PlatformError::Udev("enumerator failed".into(), err))?; + enumerator + .match_subsystem("leds") + .map_err(|err| PlatformError::Udev("match_subsystem failed".into(), err))?; + + for dev in enumerator + .scan_devices() + .map_err(|err| PlatformError::Udev("scan_devices failed".into(), err))? + { + if belongs_to_scsi(dev.syspath(), &scsi_path) { + let dynamic = DynamicLed::from_syspath(dev.syspath().to_path_buf())?; + info!( + "Found exact SCSI Dynamic Lighting LED {:?} for {}", + dev.sysname(), + scsi_path.display() + ); + return Ok(Self { dynamic }); + } + } + + Err(PlatformError::MissingFunction(format!( + "Dynamic Lighting LED for {} is not registered yet", + scsi_path.display() + ))) + } + + /// Find a `ScsiLed` for a specific `/dev/sdX` or `/dev/sgN` path. + pub fn find_for_dev(dev_node: &str) -> Result { + let mut enumerator = udev::Enumerator::new().map_err(|e| { + warn!("ScsiLed udev enumerator failed: {e}"); + PlatformError::Udev("enumerator failed".into(), e) + })?; + enumerator.match_subsystem("block").map_err(|e| { + warn!("ScsiLed match_subsystem failed: {e}"); + PlatformError::Udev("match block failed".into(), e) + })?; + + for dev in enumerator.scan_devices().map_err(|e| { + warn!("ScsiLed scan_devices failed: {e}"); + PlatformError::Udev("scan failed".into(), e) + })? { + if let Some(node) = dev.devnode() + && node.to_string_lossy() == dev_node + { + return Self::find_for_block(&dev); + } + } + + Err(PlatformError::MissingFunction(format!( + "No block device found for {dev_node}" + ))) + } + + /// Check if an ASUS SCSI Dynamic Lighting LED is available on the system. + pub fn is_available() -> bool { + Self::new().is_ok() + } + + /// Return reference to inner `DynamicLed`. + pub fn dynamic(&self) -> &DynamicLed { + &self.dynamic + } + + /// Return path to the sysfs node. + pub fn path(&self) -> &Path { + self.dynamic.path() + } + + /// Set animation effect string. + pub fn set_effect(&self, effect: &str) -> Result<()> { + self.dynamic.set_effect(effect) + } + + /// Set effect animation speed. + pub fn set_speed(&self, speed: u32) -> Result<()> { + self.dynamic.set_supported_speed(speed) + } + + /// Set effect animation direction ("right" or "left"). + pub fn set_direction(&self, direction: &str) -> Result<()> { + self.dynamic.set_supported_direction(direction) + } + + /// Set palette colors formatted as `(r, g, b)`. + pub fn set_palette_colors(&self, colors: &[(u8, u8, u8)]) -> Result<()> { + self.dynamic.set_palette_colors(colors) + } + + /// Write raw RGB bytes to the direct buffer. + pub fn write_direct(&self, data: &[u8]) -> Result<()> { + self.dynamic.write_direct(data) + } + + /// Set brightness (0..=255). + pub fn set_brightness(&self, brightness: u8) -> Result<()> { + self.dynamic.set_brightness(brightness) + } + + /// Get brightness (0..=255). + pub fn get_brightness(&self) -> Result { + self.dynamic.get_brightness() + } +} + +fn belongs_to_scsi(led_path: &Path, scsi_path: &Path) -> bool { + led_path.starts_with(scsi_path) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + #[test] + fn matches_only_exact_scsi_ancestry() { + let scsi = Path::new("/sys/devices/usb/2:0:0:0"); + assert!(super::belongs_to_scsi( + Path::new("/sys/devices/usb/2:0:0:0/leds/asus-aura-scsi"), + scsi + )); + assert!(!super::belongs_to_scsi( + Path::new("/sys/devices/usb/2:0:0:01/leds/asus-aura-scsi"), + scsi + )); + } +} diff --git a/rog-scsi/Cargo.toml b/rog-scsi/Cargo.toml index cafb9f1d2..6e8fba9ef 100644 --- a/rog-scsi/Cargo.toml +++ b/rog-scsi/Cargo.toml @@ -15,7 +15,6 @@ default = ["dbus", "ron"] dbus = ["zbus"] [dependencies] -libc.workspace = true serde.workspace = true zbus = { workspace = true, optional = true } diff --git a/rog-scsi/src/builtin_modes.rs b/rog-scsi/src/builtin_modes.rs index 0eb983cd6..4175e9655 100644 --- a/rog-scsi/src/builtin_modes.rs +++ b/rog-scsi/src/builtin_modes.rs @@ -6,8 +6,6 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type, Value}; use crate::error::Error; -use crate::scsi::{apply_task, dir_task, mode_task, rgb_task, save_task, speed_task}; -use crate::sg::Task; #[cfg_attr(feature = "dbus", derive(Type, Value, OwnedValue))] #[derive(Debug, Clone, PartialEq, Eq, Copy, Deserialize, Serialize)] @@ -358,39 +356,3 @@ impl Display for AuraEffect { writeln!(f, "}}") } } - -impl From<&AuraEffect> for Vec { - fn from(effect: &AuraEffect) -> Self { - let mut tasks = Vec::new(); - - tasks.append(&mut vec![ - mode_task(effect.mode as u8), - rgb_task(0, &effect.colour1.into()), - rgb_task(1, &effect.colour2.into()), - rgb_task(2, &effect.colour3.into()), - rgb_task(3, &effect.colour4.into()), - ]); - - if !matches!(effect.mode, AuraMode::Static | AuraMode::Off) { - tasks.push(speed_task(effect.speed as u8)); - } - if matches!( - effect.mode, - AuraMode::RainbowWave - | AuraMode::ChaseFade - | AuraMode::RainbowCycleChaseFade - | AuraMode::Chase - | AuraMode::RainbowCycleChase - | AuraMode::RainbowCycleWave - | AuraMode::RainbowPulseChase - ) { - tasks.push(dir_task(effect.direction as u8)); - } - - tasks.append(&mut vec![ - apply_task(), - save_task(), - ]); - tasks - } -} diff --git a/rog-scsi/src/lib.rs b/rog-scsi/src/lib.rs index 1fc382493..f300fb277 100644 --- a/rog-scsi/src/lib.rs +++ b/rog-scsi/src/lib.rs @@ -1,12 +1,9 @@ mod builtin_modes; mod error; -mod scsi; -pub mod sg; pub use builtin_modes::*; pub use error::*; use serde::{Deserialize, Serialize}; -pub use sg::{Device, Task}; pub const PROD_SCSI_ARION: &str = "1932"; @@ -43,7 +40,3 @@ impl From for &str { } } } - -pub fn open_device(path: &str) -> Result { - Device::open(path) -} diff --git a/rog-scsi/src/scsi.rs b/rog-scsi/src/scsi.rs deleted file mode 100644 index 0ae6af713..000000000 --- a/rog-scsi/src/scsi.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::sg::{Direction, Task}; - -static ENE_APPLY_VAL: u8 = 0x01; // Value for Apply Changes Register -static ENE_SAVE_VAL: u8 = 0xaa; - -static ENE_REG_MODE: u32 = 0x8021; // Mode Selection Register -static ENE_REG_SPEED: u32 = 0x8022; // Speed Control Register -static ENE_REG_DIRECTION: u32 = 0x8023; // Direction Control Register - -static ENE_REG_APPLY: u32 = 0x80a0; -static _ENE_REG_COLORS_DIRECT_V2: u32 = 0x8100; // to read the colurs -static ENE_REG_COLORS_EFFECT_V2: u32 = 0x8160; - -fn data(reg: u32, arg_count: u8) -> [u8; 16] { - let mut cdb = [0u8; 16]; - cdb[0] = 0xec; - cdb[1] = 0x41; - cdb[2] = 0x53; - cdb[3] = ((reg >> 8) & 0x00ff) as u8; - cdb[4] = (reg & 0x00ff) as u8; - cdb[5] = 0x00; - cdb[6] = 0x00; - cdb[7] = 0x00; - cdb[8] = 0x00; - cdb[9] = 0x00; - cdb[10] = 0x00; - cdb[11] = 0x00; - cdb[12] = 0x00; - cdb[13] = arg_count; // how many u8 in data packet - cdb[14] = 0x00; - cdb[15] = 0x00; - cdb -} - -pub(crate) fn rgb_task(led: u32, rgb: &[u8; 3]) -> Task { - let mut task = Task::new(); - task.set_cdb(data(led * 3 + ENE_REG_COLORS_EFFECT_V2, 3).as_slice()); - task.set_data(rgb, Direction::ToDevice); - task -} - -/// 0-13 -pub(crate) fn mode_task(mode: u8) -> Task { - let mut task = Task::new(); - task.set_cdb(data(ENE_REG_MODE, 1).as_slice()); - task.set_data(&[mode.min(13)], Direction::ToDevice); - task -} - -/// 0-4, fast to slow -pub(crate) fn speed_task(speed: u8) -> Task { - let mut task = Task::new(); - task.set_cdb(data(ENE_REG_SPEED, 1).as_slice()); - task.set_data(&[speed.min(4)], Direction::ToDevice); - task -} - -/// 0 = forward, 1 = backward -pub(crate) fn dir_task(mode: u8) -> Task { - let mut task = Task::new(); - task.set_cdb(data(ENE_REG_DIRECTION, 1).as_slice()); - task.set_data(&[mode.min(1)], Direction::ToDevice); - task -} - -pub(crate) fn apply_task() -> Task { - let mut task = Task::new(); - task.set_cdb(data(ENE_REG_APPLY, 1).as_slice()); - task.set_data(&[ENE_APPLY_VAL], Direction::ToDevice); - task -} - -pub(crate) fn save_task() -> Task { - let mut task = Task::new(); - task.set_cdb(data(ENE_REG_APPLY, 1).as_slice()); - task.set_data(&[ENE_SAVE_VAL], Direction::ToDevice); - task -} diff --git a/rog-scsi/src/sg.rs b/rog-scsi/src/sg.rs deleted file mode 100644 index c0359805e..000000000 --- a/rog-scsi/src/sg.rs +++ /dev/null @@ -1,219 +0,0 @@ -use std::ffi::c_void; -use std::fs::{File, OpenOptions}; -use std::io; -use std::os::unix::fs::OpenOptionsExt; -use std::os::unix::io::{AsRawFd, RawFd}; -use std::path::Path; - -pub const SG_DXFER_NONE: i32 = -1; -pub const SG_DXFER_TO_DEV: i32 = -2; -pub const SG_DXFER_FROM_DEV: i32 = -3; -pub const SG_DXFER_TO_FROM_DEV: i32 = -4; - -pub const SG_INFO_OK_MASK: u32 = 0x1; -pub const SG_INFO_OK: u32 = 0x0; -pub const SG_IO: u64 = 0x2285; - -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct SgIoHdr { - pub interface_id: std::os::raw::c_int, - pub dxfer_direction: std::os::raw::c_int, - pub cmd_len: u8, - pub mx_sb_len: u8, - pub iovec_count: u16, - pub dxfer_len: u32, - pub dxferp: *mut c_void, - pub cmdp: *mut u8, - pub sbp: *mut u8, - pub timeout: u32, - pub flags: u32, - pub pack_id: std::os::raw::c_int, - pub usr_ptr: *mut c_void, - pub status: u8, - pub masked_status: u8, - pub msg_status: u8, - pub sb_len_wr: u8, - pub host_status: u16, - pub driver_status: u16, - pub resid: i32, - pub duration: u32, - pub info: u32, -} - -impl Default for SgIoHdr { - fn default() -> Self { - Self { - interface_id: b'S' as std::os::raw::c_int, - dxfer_direction: SG_DXFER_NONE, - cmd_len: 0, - mx_sb_len: 0, - iovec_count: 0, - dxfer_len: 0, - dxferp: std::ptr::null_mut(), - cmdp: std::ptr::null_mut(), - sbp: std::ptr::null_mut(), - timeout: 0, - flags: 0, - pack_id: 0, - usr_ptr: std::ptr::null_mut(), - status: 0, - masked_status: 0, - msg_status: 0, - sb_len_wr: 0, - host_status: 0, - driver_status: 0, - resid: 0, - duration: 0, - info: 0, - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum Direction { - None, - ToDevice, - FromDevice, - ToFromDevice, -} - -impl Direction { - fn to_underlying(self) -> std::os::raw::c_int { - match self { - Direction::None => SG_DXFER_NONE, - Direction::ToDevice => SG_DXFER_TO_DEV, - Direction::FromDevice => SG_DXFER_FROM_DEV, - Direction::ToFromDevice => SG_DXFER_TO_FROM_DEV, - } - } -} - -#[derive(Clone, Debug)] -pub struct Task { - inner: SgIoHdr, - cmd: Vec, - data: Vec, - sense: Vec, -} - -impl Default for Task { - fn default() -> Self { - Self::new() - } -} - -// SAFETY: Task manages its internal buffers and SgIoHdr raw pointers. The raw pointers -// are updated prior to any ioctl execution to point directly to owned heap vectors, making -// Send and Sync safe across thread boundaries. -unsafe impl Send for Task {} -unsafe impl Sync for Task {} - -impl Task { - pub fn new() -> Self { - Task { - inner: SgIoHdr::default(), - cmd: Vec::new(), - data: Vec::new(), - sense: Vec::new(), - } - } - - /// Prepares raw internal pointers in SgIoHdr to match current buffer memory addresses. - fn sync_pointers(&mut self) { - if !self.cmd.is_empty() { - self.inner.cmdp = self.cmd.as_mut_ptr(); - self.inner.cmd_len = self.cmd.len() as u8; - } else { - self.inner.cmdp = std::ptr::null_mut(); - self.inner.cmd_len = 0; - } - - if !self.data.is_empty() { - self.inner.dxferp = self.data.as_mut_ptr() as *mut c_void; - self.inner.dxfer_len = self.data.len() as u32; - } else { - self.inner.dxferp = std::ptr::null_mut(); - self.inner.dxfer_len = 0; - } - - if !self.sense.is_empty() { - self.inner.sbp = self.sense.as_mut_ptr(); - self.inner.mx_sb_len = self.sense.len() as u8; - } else { - self.inner.sbp = std::ptr::null_mut(); - self.inner.mx_sb_len = 0; - } - } - - pub fn set_cdb(&mut self, buf: &[u8]) -> &mut Self { - self.cmd = buf.to_vec(); - self.sync_pointers(); - self - } - - pub fn set_data(&mut self, buf: &[u8], direction: Direction) -> &mut Self { - self.data = buf.to_vec(); - self.inner.dxfer_direction = direction.to_underlying(); - self.sync_pointers(); - self - } - - pub fn status(&self) -> u8 { - self.inner.status - } - - pub fn host_status(&self) -> u16 { - self.inner.host_status - } - - pub fn driver_status(&self) -> u16 { - self.inner.driver_status - } - - pub fn ok(&self) -> bool { - (self.inner.info & SG_INFO_OK_MASK) == SG_INFO_OK - } -} - -pub struct Device(File); - -impl Device { - pub fn open>(path: P) -> io::Result { - Ok(Device( - OpenOptions::new() - .read(true) - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(path)?, - )) - } - - /// Performs a synchronous SCSI IO operation via ioctl. On success the kernel - /// has written command status, sense data and any FromDevice payload back - /// into `task`, so results can be read via its accessors. - pub fn perform(&self, task: &mut Task) -> io::Result<()> { - task.sync_pointers(); - - #[cfg(target_env = "musl")] - let request = SG_IO as i32; - #[cfg(not(target_env = "musl"))] - let request: u64 = SG_IO; - - // SAFETY: The raw file descriptor is open and valid, and task has valid synced - // pointers into its own buffers, which stay alive for the duration of the - // synchronous ioctl. - let ret = unsafe { libc::ioctl(self.0.as_raw_fd(), request, &mut task.inner) }; - if ret == -1 { - Err(io::Error::last_os_error()) - } else { - Ok(()) - } - } -} - -impl AsRawFd for Device { - fn as_raw_fd(&self) -> RawFd { - self.0.as_raw_fd() - } -}