From abd92db16bf5ae58fc11d63c557c2003e8283b59 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Mon, 13 Jul 2026 10:46:52 +0200 Subject: [PATCH 1/4] feat(rog-platform): port hidraw ioctl backend to rustix --- rog-platform/Cargo.toml | 1 + rog-platform/src/hid_raw.rs | 269 +++++++++++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/rog-platform/Cargo.toml b/rog-platform/Cargo.toml index ed5a3e015..d0ecf5a24 100644 --- a/rog-platform/Cargo.toml +++ b/rog-platform/Cargo.toml @@ -17,6 +17,7 @@ udev.workspace = true inotify.workspace = true rusb.workspace = true thiserror.workspace = true +rustix = { version = "^0.38", features = ["fs"] } [dev-dependencies] serde_json = { version = "1.0", default-features = false, features = ["preserve_order"] } diff --git a/rog-platform/src/hid_raw.rs b/rog-platform/src/hid_raw.rs index abb349c68..43fb1748b 100644 --- a/rog-platform/src/hid_raw.rs +++ b/rog-platform/src/hid_raw.rs @@ -1,13 +1,89 @@ use std::cell::RefCell; use std::fs::{File, OpenOptions}; use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::io::AsRawFd; use std::path::PathBuf; -use log::{info, warn}; +use log::{error, info, warn}; use udev::Device; use crate::error::{PlatformError, Result}; +/// Matches the kernel `struct hidraw_devinfo` (8 bytes total). +#[repr(C)] +pub struct HidrawDevinfo { + pub bustype: u32, + pub vendor: i16, + pub product: i16, +} + +// rustix custom Ioctl trait implementations +struct GetRawInfo { + info: HidrawDevinfo, +} + +unsafe impl rustix::ioctl::Ioctl for GetRawInfo { + type Output = HidrawDevinfo; + const OPCODE: rustix::ioctl::Opcode = rustix::ioctl::Opcode::old(0x80084803); + const IS_MUTATING: bool = true; + + fn as_ptr(&mut self) -> *mut std::ffi::c_void { + &mut self.info as *mut HidrawDevinfo as *mut std::ffi::c_void + } + + unsafe fn output_from_ptr( + _out: rustix::ioctl::IoctlOutput, + extract_output: *mut std::ffi::c_void, + ) -> rustix::io::Result { + Ok(std::ptr::read(extract_output as *const HidrawDevinfo)) + } +} + +struct SetFeatureReport { + payload: [u8; N], +} + +unsafe impl rustix::ioctl::Ioctl for SetFeatureReport { + type Output = (); + const OPCODE: rustix::ioctl::Opcode = + rustix::ioctl::Opcode::from_components(rustix::ioctl::Direction::ReadWrite, b'H', 0x06, N); + const IS_MUTATING: bool = false; + + fn as_ptr(&mut self) -> *mut std::ffi::c_void { + self.payload.as_mut_ptr() as *mut std::ffi::c_void + } + + unsafe fn output_from_ptr( + _out: rustix::ioctl::IoctlOutput, + _extract_output: *mut std::ffi::c_void, + ) -> rustix::io::Result { + Ok(()) + } +} + +struct GetFeatureReport { + buf: [u8; N], +} + +unsafe impl rustix::ioctl::Ioctl for GetFeatureReport { + type Output = [u8; N]; + const OPCODE: rustix::ioctl::Opcode = + rustix::ioctl::Opcode::from_components(rustix::ioctl::Direction::ReadWrite, b'H', 0x07, N); + const IS_MUTATING: bool = true; + + fn as_ptr(&mut self) -> *mut std::ffi::c_void { + self.buf.as_mut_ptr() as *mut std::ffi::c_void + } + + unsafe fn output_from_ptr( + _out: rustix::ioctl::IoctlOutput, + extract_output: *mut std::ffi::c_void, + ) -> rustix::io::Result { + Ok(std::ptr::read(extract_output as *const [u8; N])) + } +} + /// A USB device that utilizes hidraw for I/O #[derive(Debug)] pub struct HidRaw { @@ -108,10 +184,70 @@ impl HidRaw { )) } + /// Build a `HidRaw` from an I2C-HID hidraw endpoint. Opens R/W so that we + /// can use HIDIOCGFEATURE / HIDIOCSFEATURE on LampArray devices. + pub fn from_i2c_device(endpoint: Device, prod_id: &str) -> Result { + let sysname_dbg = endpoint.sysname().to_string_lossy().to_string(); + info!( + "HidRaw::from_i2c_device: begin sysname={} prod_id={}", + sysname_dbg, prod_id + ); + info!( + "HidRaw::from_i2c_device: querying devnode for sysname={}", + sysname_dbg + ); + let dev_node = endpoint.devnode().ok_or_else(|| { + PlatformError::MissingFunction("I2C-HID endpoint has no devnode".to_string()) + })?; + info!( + "HidRaw::from_i2c_device: devnode={:?} sysname={}", + dev_node, sysname_dbg + ); + info!( + "HidRaw::from_i2c_device: opening {:?} R/W (O_NONBLOCK) for prod_id={}", + dev_node, prod_id + ); + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32) + .open(dev_node) + .map_err(|e| PlatformError::IoPath(dev_node.to_string_lossy().to_string(), e))?; + let fd = file.as_raw_fd(); + info!( + "HidRaw::from_i2c_device: file opened fd={} dev_node={:?}", + fd, dev_node + ); + info!( + "HidRaw::from_i2c_device: about to query syspath for sysname={}", + sysname_dbg + ); + let syspath = endpoint.syspath().to_path_buf(); + info!( + "HidRaw::from_i2c_device: syspath={:?} sysname={}", + syspath, sysname_dbg + ); + info!( + "HidRaw::from_i2c_device: returning OK for sysname={} fd={}", + sysname_dbg, fd + ); + Ok(Self { + file: RefCell::new(file), + devfs_path: dev_node.to_owned(), + prod_id: prod_id.to_string(), + syspath, + _device_bcd: 0, + }) + } + pub fn prod_id(&self) -> &str { &self.prod_id } + pub fn devfs_path(&self) -> &PathBuf { + &self.devfs_path + } + /// 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() { @@ -129,4 +265,135 @@ impl HidRaw { let mut dev = Device::from_syspath(&self.syspath)?; Ok(dev.set_attribute_value("power/wakeup", "disabled")?) } + + /// HIDIOCGRAWINFO -> kernel hidraw_devinfo (bustype, vendor, product). + pub fn raw_info(&self) -> Result { + let file = self + .file + .try_borrow() + .map_err(|_| PlatformError::MissingFunction("hidraw file busy".into()))?; + let fd = file.as_raw_fd(); + info!( + "HidRaw::raw_info: fd={} struct_size={}", + fd, + std::mem::size_of::() + ); + + let op = GetRawInfo { + info: HidrawDevinfo { + bustype: 0, + vendor: 0, + product: 0, + }, + }; + // SAFETY: We pass a pointer to a 8-byte struct matching the kernel's + // hidraw_devinfo layout; the ioctl number encodes that size. + let info = unsafe { rustix::ioctl::ioctl(&*file, op) }.map_err(|err| { + let err = std::io::Error::from(err); + error!( + "HidRaw::raw_info: ioctl HIDIOCGRAWINFO failed on {:?}: {}", + self.devfs_path, err + ); + PlatformError::IoPath(self.devfs_path.to_string_lossy().to_string(), err) + })?; + + info!( + "HidRaw::raw_info: ok bus={:#x} vendor={:#06x} product={:#06x}", + info.bustype, info.vendor as u16, info.product as u16 + ); + Ok(info) + } + + /// HIDIOCSFEATURE(len) - send a feature report. + pub fn set_feature_report(&self, payload: &[u8]) -> Result<()> { + let file = self + .file + .try_borrow() + .map_err(|_| PlatformError::MissingFunction("hidraw file busy".into()))?; + let len = payload.len(); + match len { + 2 => { + let mut data = [0u8; 2]; + data.copy_from_slice(payload); + let op = SetFeatureReport { payload: data }; + unsafe { + rustix::ioctl::ioctl(&*file, op) + }.map_err(|err| { + let err = std::io::Error::from(err); + error!( + "HidRaw::set_feature_report: ioctl HIDIOCSFEATURE(len=2) failed on {:?}: {}", + self.devfs_path, + err + ); + PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + ) + })?; + } + 10 => { + let mut data = [0u8; 10]; + data.copy_from_slice(payload); + let op = SetFeatureReport { payload: data }; + unsafe { + rustix::ioctl::ioctl(&*file, op) + }.map_err(|err| { + let err = std::io::Error::from(err); + error!( + "HidRaw::set_feature_report: ioctl HIDIOCSFEATURE(len=10) failed on {:?}: {}", + self.devfs_path, + err + ); + PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + ) + })?; + } + _ => { + return Err(PlatformError::MissingFunction(format!( + "Unsupported set_feature_report payload length: {}", + len + ))); + } + } + Ok(()) + } + + /// HIDIOCGFEATURE(len) - read a feature report. Buffer[0] must hold the + /// report ID before the call. + pub fn get_feature_report(&self, buf: &mut [u8]) -> Result { + let file = self + .file + .try_borrow() + .map_err(|_| PlatformError::MissingFunction("hidraw file busy".into()))?; + let len = buf.len(); + match len { + 23 => { + let mut data = [0u8; 23]; + data.copy_from_slice(buf); + let op = GetFeatureReport { buf: data }; + let res = unsafe { + rustix::ioctl::ioctl(&*file, op) + }.map_err(|err| { + let err = std::io::Error::from(err); + error!( + "HidRaw::get_feature_report: ioctl HIDIOCGFEATURE(len=23) failed on {:?}: {}", + self.devfs_path, + err + ); + PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + ) + })?; + buf.copy_from_slice(&res); + Ok(23) + } + _ => Err(PlatformError::MissingFunction(format!( + "Unsupported get_feature_report buffer length: {}", + len + ))), + } + } } From 96fae7bffcb0cafc11bacf30fc28a7cb2cfac6b5 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Mon, 13 Jul 2026 10:47:07 +0200 Subject: [PATCH 2/4] feat(asusd): implement aura_lamparray core logic, zbus interface, and effects --- asusd/src/aura_lamparray/effects.rs | 176 +++++++++++++ asusd/src/aura_lamparray/mod.rs | 319 ++++++++++++++++++++++++ asusd/src/aura_lamparray/trait_impls.rs | 285 +++++++++++++++++++++ asusd/src/lib.rs | 1 + 4 files changed, 781 insertions(+) create mode 100644 asusd/src/aura_lamparray/effects.rs create mode 100644 asusd/src/aura_lamparray/mod.rs create mode 100644 asusd/src/aura_lamparray/trait_impls.rs diff --git a/asusd/src/aura_lamparray/effects.rs b/asusd/src/aura_lamparray/effects.rs new file mode 100644 index 000000000..a5c686507 --- /dev/null +++ b/asusd/src/aura_lamparray/effects.rs @@ -0,0 +1,176 @@ +//! LampArray dynamic effects: frame generation for +//! Breathe / RainbowCycle / RainbowWave / Pulse. +//! +//! Split out from `mod.rs` so the animation math stays isolated from the +//! outer `LampArray` bookkeeping (config lock, effect_task handle). +use std::sync::Arc; +use std::time::Duration; + +use log::info; +use rog_aura::{AuraEffect, AuraModeNum, Speed}; +use rog_platform::hid_raw::HidRaw; +use tokio::runtime::Handle; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; + +use crate::error::RogError; + +/// Spawn the animation task. Probes LampCount once (blocking-ish, but only +/// runs on effect switch), then loops at ~30 FPS pushing LampRangeUpdate +/// feature reports until aborted. +pub async fn spawn_effect_task( + hid: Arc>, + runtime_handle: &Handle, + mode: AuraEffect, + intensity: u8, +) -> Result, RogError> { + // Probe LampCount once, up-front, so the task doesn't need to touch + // GET_FEATURE at 30 FPS. + let lamp_count = { + let hid = hid.lock().await; + hid.set_feature_report(&[ + 0x46, 0x00, + ])?; + let mut attr = vec![0u8; 23]; + attr[0] = 0x41; + hid.get_feature_report(&mut attr)?; + u16::from_le_bytes([ + attr[1], attr[2], + ]) + }; + if lamp_count == 0 { + return Err(RogError::MissingFunction( + "LampArray reports zero lamps".to_string(), + )); + } + let period_ms = speed_to_period_ms(mode.speed); + let frame_ms: u64 = 33; // ~30 FPS + let total_frames: u32 = ((period_ms as f32) / (frame_ms as f32)).max(1.0) as u32; + let mode_kind = mode.mode; + let colour1 = mode.colour1; + info!( + "lamparray_effect_task: starting mode={:?} period={}ms frames={} rgb1=({},{},{}) intensity_cap={}", + mode_kind, period_ms, total_frames, colour1.r, colour1.g, colour1.b, intensity + ); + + let hid_for_task = hid.clone(); + let handle = runtime_handle.spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_millis(frame_ms)); + // Discard the immediate first tick so the loop pacing is stable. + ticker.tick().await; + let mut frame: u32 = 0; + loop { + let t = (frame % total_frames) as f32 / (total_frames as f32); + let (r, g, b, i) = match mode_kind { + AuraModeNum::Breathe => { + // Pure sinusoid on I; keep colour1 as the hue. + let s = (2.0 * std::f32::consts::PI * t).sin(); + let level = ((s + 1.0) * 0.5) * intensity as f32; + ( + colour1.r, + colour1.g, + colour1.b, + level.round().clamp(0.0, 255.0) as u8, + ) + } + AuraModeNum::Pulse => { + // Sharp attack, slow decay — "heartbeat" style. + let phase = t; + let level = if phase < 0.2 { + (phase / 0.2) * intensity as f32 + } else { + (1.0 - (phase - 0.2) / 0.8) * intensity as f32 + }; + ( + colour1.r, + colour1.g, + colour1.b, + level.round().clamp(0.0, 255.0) as u8, + ) + } + AuraModeNum::RainbowCycle => { + let hue = (t * 360.0) % 360.0; + let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0); + (r, g, b, intensity) + } + AuraModeNum::RainbowWave => { + // On LampCount=1 there is no spatial "wave" to encode — + // a single lamp is scalar. Keep the same hue rotation as + // RainbowCycle but sweep hue backwards for a visual + // difference between the two modes. + let hue = (360.0 - (t * 360.0)) % 360.0; + let (r, g, b) = hsv_to_rgb(hue, 1.0, 1.0); + (r, g, b, intensity) + } + // Should not happen: Static and unhandled modes go through + // the single-push path in write_effect. + _ => (colour1.r, colour1.g, colour1.b, intensity), + }; + let last = lamp_count - 1; + let payload = [ + 0x45, + 0x01, + 0x00, + 0x00, + (last & 0xff) as u8, + ((last >> 8) & 0xff) as u8, + r, + g, + b, + i, + ]; + // Hold the hid lock only for the write, so brightness/other + // callers can interleave between frames. + { + let hid = hid_for_task.lock().await; + if let Err(e) = hid.set_feature_report(&payload) { + log::warn!( + "lamparray_effect_task: set_feature_report failed: {e:?} — stopping" + ); + break; + } + } + frame = frame.wrapping_add(1); + ticker.tick().await; + } + info!("lamparray_effect_task: exited"); + }); + Ok(handle) +} + +/// Map the abstract rog_aura::Speed enum to a period in milliseconds for +/// one full cycle of the animation. +pub fn speed_to_period_ms(s: Speed) -> u32 { + match s { + Speed::Low => 4000, + Speed::Med => 2000, + Speed::High => 800, + } +} + +/// Convert an HSV colour (hue in degrees, s/v in [0, 1]) to 8-bit RGB. +/// Standard formula from https://en.wikipedia.org/wiki/HSL_and_HSV. +pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let c = v * s; + let hp = (h / 60.0) % 6.0; + let x = c * (1.0 - ((hp % 2.0) - 1.0).abs()); + let (r1, g1, b1) = if hp < 1.0 { + (c, x, 0.0) + } else if hp < 2.0 { + (x, c, 0.0) + } else if hp < 3.0 { + (0.0, c, x) + } else if hp < 4.0 { + (0.0, x, c) + } else if hp < 5.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + let m = v - c; + ( + ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, + ) +} diff --git a/asusd/src/aura_lamparray/mod.rs b/asusd/src/aura_lamparray/mod.rs new file mode 100644 index 000000000..d4e9e2a4f --- /dev/null +++ b/asusd/src/aura_lamparray/mod.rs @@ -0,0 +1,319 @@ +//! `aura_lamparray` — Microsoft HID LampArray (Usage Page 0x59) backend. +//! +//! This module is a sibling of `aura_slash`, `aura_anime`, `aura_scsi` and +//! deliberately kept separate from `aura_laptop`. The latter drives the ASUS +//! proprietary Aura HID protocol used by USB/asus-wmi keyboards; LampArray +//! is a standards-based feature-report protocol exposed by I2C-HID +//! controllers on newer ASUS TUF laptops (e.g. FA608WV / ITE5570). The two +//! share nothing at the wire level — collapsing them into one struct with +//! an `is_lamparray` flag turned out to be a source of coupling bugs +//! (config-lock deadlocks, double-push races on brightness change). +//! +//! Public surface: +//! * [`LampArray`] — owning struct, holds the `HidRaw` node, config, and +//! the currently-running dynamic-effect task handle. +//! * [`LampArrayZbus`] — zbus interface at `/xyz/ljones/aura/lamparray_`. +//! +//! The passive chip does not do animations on-die: the host must push +//! `LampRangeUpdate` (report 0x45) frames at ~30 FPS from a tokio task. +//! That task is created via [`LampArray::spawn_effect`] and cancelled by +//! [`LampArray::stop_effect_task`] on every new effect write, so two +//! loops can never race the hid lock. +use std::sync::Arc; + +use log::info; +use rog_aura::{AuraEffect, AuraModeNum, LedBrightness}; +use rog_platform::hid_raw::HidRaw; +use tokio::runtime::Handle; +use tokio::sync::{Mutex, MutexGuard}; +use tokio::task::JoinHandle; + +use crate::aura_laptop::config::AuraConfig; +use crate::error::RogError; + +pub mod effects; +pub mod trait_impls; + +use effects::spawn_effect_task; + +#[derive(Debug, Clone)] +pub struct LampArray { + /// Underlying hidraw node. Feature reports are pushed through + /// `HidRaw::set_feature_report` / `get_feature_report`. + pub hid: Arc>, + /// Shared with the zbus interface. Owns brightness, current mode and the + /// stored builtin effects (colour1/colour2/speed per mode). + pub config: Arc>, + /// Handle for the currently-running LampArray dynamic-effect task. + /// Aborted on every effect write so we never accumulate loops. + pub effect_task: Arc>>>, + /// Tokio runtime handle captured at construction. The dynamic-effect + /// task must be spawned via `Handle::spawn` because methods on this + /// struct are invoked from the zbus executor thread, which is not a + /// Tokio runtime thread — bare `tokio::spawn()` would panic there. + pub runtime_handle: Handle, +} + +impl LampArray { + pub fn new(hid: Arc>, config: Arc>) -> Self { + info!("LampArray constructed with runtime_handle captured"); + Self { + hid, + config, + effect_task: Arc::new(Mutex::new(None)), + runtime_handle: Handle::current(), + } + } + + pub async fn do_initialization(&self) -> Result<(), RogError> { + Ok(()) + } + + pub async fn lock_config(&self) -> MutexGuard<'_, AuraConfig> { + self.config.lock().await + } + + /// Write the currently active mode from config to the device. Mirrors + /// `Aura::write_current_config_mode` but for the LampArray path only. + /// Caller owns the config lock and passes it in — same reason as + /// `write_effect_locked`. + pub async fn write_current_config_mode(&self, config: &mut AuraConfig) -> Result<(), RogError> { + if config.multizone_on { + let mode = config.current_mode; + let mut create = false; + if config.multizone.is_none() { + create = true; + } else if let Some(multizones) = config.multizone.as_ref() { + if !multizones.contains_key(&mode) { + create = true; + } + } + if create { + info!("No user-set config for zone founding, attempting a default"); + config.create_multizone_default()?; + } + if let Some(multizones) = config.multizone.as_mut() { + if let Some(set) = multizones.get(&mode) { + for mode in set.clone() { + self.write_effect_locked(config, &mode).await?; + } + } + } + } else { + let mode = config.current_mode; + if let Some(effect) = config.builtins.get(&mode).cloned() { + self.write_effect_locked(config, &effect).await?; + } + } + Ok(()) + } + + /// LampArray helper — write the current static colour to the whole + /// keyboard at the requested intensity (0-255). The protocol is the + /// Microsoft HID LampArray usage page: + /// * report 0x46 — "autonomous mode" toggle (we disable so the OS owns) + /// * report 0x41 — LampArrayAttributes (read to discover LampCount) + /// * report 0x45 — LampArrayMultiUpdate / RangeUpdate + pub async fn push_rgb_i(&self, r: u8, g: u8, b: u8, intensity: u8) -> Result<(), RogError> { + let hid = self.hid.lock().await; + // Disable autonomous so we own the lamp array + hid.set_feature_report(&[ + 0x46, 0x00, + ])?; + // Read LampArrayAttributes to discover the lamp count + let mut attr = vec![0u8; 23]; + attr[0] = 0x41; + hid.get_feature_report(&mut attr)?; + let lamp_count = u16::from_le_bytes([ + attr[1], attr[2], + ]); + if lamp_count == 0 { + return Err(RogError::MissingFunction( + "LampArray reports zero lamps".to_string(), + )); + } + let last = lamp_count - 1; + // RangeUpdate: 0x45, flags, start_lo, start_hi, end_lo, end_hi, r,g,b,i + let payload = [ + 0x45, + 0x01, + 0x00, + 0x00, + (last & 0xff) as u8, + ((last >> 8) & 0xff) as u8, + r, + g, + b, + intensity, + ]; + hid.set_feature_report(&payload)?; + info!( + "LampArray ready: LampCount={lamp_count} rgb=({r:02x},{g:02x},{b:02x}) i={intensity}" + ); + Ok(()) + } + + /// Write a single effect to a LampArray device. + /// + /// IMPORTANT: this used to take `self.config.lock().await` to read + /// brightness, but the typical call chain comes from a caller that ALREADY + /// holds the config lock (e.g. `write_current_config_mode`, + /// `set_led_mode_data`, `reload`). Re-locking caused an async deadlock at + /// init time, which made systemd kill asusd on the `Type=dbus` timeout. + /// We now use `try_lock` and fall back to `LedBrightness::Med` when the + /// lock is held by the caller. Callers that already have a locked + /// `AuraConfig` should prefer [`LampArray::write_effect_locked`] to avoid + /// the fallback path entirely. + pub async fn write_effect(&self, mode: &AuraEffect) -> Result<(), RogError> { + // Always stop any previous animation loop before doing anything else, + // so two effect tasks never race to push frames. + self.stop_effect_task().await; + let brightness = match self.config.try_lock() { + Ok(cfg) => cfg.brightness, + Err(_) => { + info!( + "lamparray_write_effect: config already locked by caller, using Med fallback" + ); + LedBrightness::Med + } + }; + let intensity = Self::brightness_to_intensity(brightness); + match mode.mode { + AuraModeNum::Static => { + let r = mode.colour1.r; + let g = mode.colour1.g; + let b = mode.colour1.b; + info!("lamparray_write_effect: Static, single push"); + info!("lamparray_write_effect_locked: about to push rgb (caller owns config lock)"); + self.push_rgb_i(r, g, b, intensity).await + } + _ => { + info!( + "lamparray_write_effect: dynamic mode {:?}, spawning effect task", + mode.mode + ); + self.spawn_effect(mode.clone(), intensity).await + } + } + } + + /// Variant for callers that already hold the config lock. Pass the + /// already-locked config in to avoid the deadlock that re-locking would + /// cause. + pub async fn write_effect_locked( + &self, + config: &AuraConfig, + mode: &AuraEffect, + ) -> Result<(), RogError> { + // Same rule as `write_effect`: kill any running animation before + // dispatching so we don't accumulate tasks across reloads. + self.stop_effect_task().await; + let intensity = Self::brightness_to_intensity(config.brightness); + match mode.mode { + AuraModeNum::Static => { + let r = mode.colour1.r; + let g = mode.colour1.g; + let b = mode.colour1.b; + info!("lamparray_write_effect_locked: Static, single push"); + info!("lamparray_write_effect_locked: about to push rgb (caller owns config lock)"); + self.push_rgb_i(r, g, b, intensity).await + } + _ => { + info!( + "lamparray_write_effect_locked: dynamic mode {:?}, spawning effect task", + mode.mode + ); + self.spawn_effect(mode.clone(), intensity).await + } + } + } + + /// Brightness -> intensity mapping for LampArray. Reuses the colour from + /// the currently active builtin effect in config so the keyboard keeps + /// the same hue when the user only changes brightness. + /// + /// Uses `try_lock` to avoid the init-time deadlock when a caller higher + /// in the stack already owns the config lock (see comment on + /// [`LampArray::write_effect`]). + pub async fn set_brightness(&self, value: u8) -> Result<(), RogError> { + let level = match value { + 0 => LedBrightness::Off, + 1 => LedBrightness::Low, + 2 => LedBrightness::Med, + _ => LedBrightness::High, + }; + let intensity = Self::brightness_to_intensity(level); + let (r, g, b) = match self.config.try_lock() { + Ok(mut cfg) => { + cfg.brightness = level; + let mode = cfg.current_mode; + if let Some(eff) = cfg.builtins.get(&mode) { + (eff.colour1.r, eff.colour1.g, eff.colour1.b) + } else { + (0xff, 0xff, 0xff) + } + } + Err(_) => { + info!( + "lamparray_set_brightness: config already locked by caller, defaulting to white" + ); + (0xff, 0xff, 0xff) + } + }; + info!("lamparray_set_brightness: about to push rgb (no lock held)"); + self.push_rgb_i(r, g, b, intensity).await + } + + /// Aura power states on LampArray - we collapse the per-zone flags into a + /// simple on/off: any zone enabled -> full intensity with the saved RGB, + /// all disabled -> intensity 0. + pub async fn set_aura_power(&self, config: &AuraConfig) -> Result<(), RogError> { + let any_on = config.enabled.states.iter().any(|s| { + // Treat the "new" zone state as on if any bit is set. + s.new_to_byte() != 0 + }); + let (r, g, b) = { + let mode = config.current_mode; + if let Some(eff) = config.builtins.get(&mode) { + (eff.colour1.r, eff.colour1.g, eff.colour1.b) + } else { + (0xff, 0xff, 0xff) + } + }; + let intensity = if any_on { 255 } else { 0 }; + // A power-state change also implies "stop whatever animation was + // running", otherwise the loop would happily override our push. + self.stop_effect_task().await; + self.push_rgb_i(r, g, b, intensity).await + } + + /// Abort the current LampArray effect task, if any. Safe to call even + /// when no task is running. + pub async fn stop_effect_task(&self) { + let mut slot = self.effect_task.lock().await; + if let Some(handle) = slot.take() { + handle.abort(); + info!("lamparray_effect_task: cancelled"); + } + } + + /// Spawn a tokio task that drives one of the dynamic LampArray effects + /// (Breathe / RainbowCycle / RainbowWave / Pulse). Delegates the frame + /// generation to `effects::spawn_effect_task`. + async fn spawn_effect(&self, mode: AuraEffect, intensity: u8) -> Result<(), RogError> { + let handle = + spawn_effect_task(self.hid.clone(), &self.runtime_handle, mode, intensity).await?; + let mut slot = self.effect_task.lock().await; + *slot = Some(handle); + Ok(()) + } + + fn brightness_to_intensity(b: LedBrightness) -> u8 { + match b { + LedBrightness::Off => 0, + LedBrightness::Low => 64, + LedBrightness::Med => 128, + LedBrightness::High => 255, + } + } +} diff --git a/asusd/src/aura_lamparray/trait_impls.rs b/asusd/src/aura_lamparray/trait_impls.rs new file mode 100644 index 000000000..399b7e173 --- /dev/null +++ b/asusd/src/aura_lamparray/trait_impls.rs @@ -0,0 +1,285 @@ +//! zbus interface for LampArray devices. +//! +//! Registered at `/xyz/ljones/aura/lamparray_` — the path must stay +//! identical to the previous incarnation so `rog-control-center` keeps +//! working across the refactor. +//! +//! Exposes the same `xyz.ljones.Aura` interface name as [`AuraZbus`] so +//! clients cannot tell the two apart — the split is purely internal. +use std::collections::BTreeMap; + +use config_traits::StdConfig; +use log::{debug, error, info, warn}; +use rog_aura::keyboard::{AuraLaptopUsbPackets, LaptopAuraPower}; +use rog_aura::{AuraDeviceType, AuraEffect, AuraModeNum, AuraZone, LedBrightness, PowerZones}; +use zbus::fdo::Error as ZbErr; +use zbus::object_server::SignalEmitter; +use zbus::zvariant::OwnedObjectPath; +use zbus::{interface, Connection}; + +use super::LampArray; +use crate::error::RogError; +use crate::{CtrlTask, Reloadable}; + +#[derive(Clone)] +pub struct LampArrayZbus(LampArray); + +impl LampArrayZbus { + pub fn new(lamparray: LampArray) -> Self { + Self(lamparray) + } + + pub async fn start_tasks( + mut self, + connection: &Connection, + path: OwnedObjectPath, + ) -> Result<(), RogError> { + self.reload() + .await + .unwrap_or_else(|err| warn!("Controller error: {}", err)); + connection + .object_server() + .at(path.clone(), self) + .await + .map_err(|e| error!("Couldn't add server at path: {path}, {e:?}")) + .ok(); + Ok(()) + } +} + +/// The main interface for changing, reading, or notifying. +/// +/// Same interface name as the USB/asus-wmi [`AuraZbus`], so downstream +/// clients (`rog-control-center`, `asusctl aura`) interact with LampArray +/// devices without any protocol awareness. +#[interface(name = "xyz.ljones.Aura")] +impl LampArrayZbus { + /// Return the device type for this Aura keyboard + #[zbus(property)] + async fn device_type(&self) -> AuraDeviceType { + self.0.config.lock().await.led_type + } + + /// Return the current LED brightness (from config — LampArray has no + /// sysfs backlight node). + #[zbus(property)] + async fn brightness(&self) -> Result { + Ok(self.0.config.lock().await.brightness) + } + + /// Set the keyboard brightness level (0-3). + #[zbus(property)] + async fn set_brightness(&mut self, brightness: LedBrightness) -> Result<(), ZbErr> { + self.0.set_brightness(brightness.into()).await?; + let mut config = self.0.config.lock().await; + config.brightness = brightness; + config.write(); + Ok(()) + } + + /// Total levels of brightness available + #[zbus(property)] + async fn supported_brightness(&self) -> Vec { + vec![ + LedBrightness::Off, + LedBrightness::Low, + LedBrightness::Med, + LedBrightness::High, + ] + } + + /// The total available modes + #[zbus(property)] + async fn supported_basic_modes(&self) -> Result, ZbErr> { + let config = self.0.config.lock().await; + Ok(config.builtins.keys().cloned().collect()) + } + + #[zbus(property)] + async fn supported_basic_zones(&self) -> Result, ZbErr> { + let config = self.0.config.lock().await; + Ok(config.support_data.basic_zones.clone()) + } + + #[zbus(property)] + async fn supported_power_zones(&self) -> Result, ZbErr> { + let config = self.0.config.lock().await; + Ok(config.support_data.power_zones.clone()) + } + + /// The current mode data + #[zbus(property)] + async fn led_mode(&self) -> Result { + if let Ok(config) = self.0.config.try_lock() { + Ok(config.current_mode) + } else { + Err(ZbErr::Failed( + "LampArray control couldn't lock self".to_string(), + )) + } + } + + /// Set an Aura effect if the effect mode or zone is supported. + /// + /// On success the aura config file is read to refresh cached values, + /// then the effect is stored and config written to disk. + #[zbus(property)] + async fn set_led_mode(&mut self, num: AuraModeNum) -> Result<(), ZbErr> { + let mut config = self.0.config.lock().await; + config.current_mode = num; + if config.brightness == LedBrightness::Off { + config.brightness = LedBrightness::Med; + } + self.0.write_current_config_mode(&mut config).await?; + // write_current_config_mode already pushed both colour and intensity + // in one HID feature report (via write_effect_locked). Avoid a + // second push that would race the colour with the white fallback in + // set_brightness. + config.write(); + Ok(()) + } + + /// The current mode data + #[zbus(property)] + async fn led_mode_data(&self) -> Result { + if let Ok(config) = self.0.config.try_lock() { + let mode = config.current_mode; + match config.builtins.get(&mode) { + Some(effect) => Ok(effect.clone()), + None => Err(ZbErr::Failed("Could not get the current effect".into())), + } + } else { + Err(ZbErr::Failed( + "LampArray control couldn't lock self".to_string(), + )) + } + } + + /// Set an Aura effect if the effect mode or zone is supported. + /// + /// On success the aura config file is read to refresh cached values, + /// then the effect is stored and config written to disk. + #[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) + { + return Err(ZbErr::NotSupported(format!( + "The Aura effect is not supported: {effect:?}" + ))); + } + if config.brightness == LedBrightness::Off { + config.brightness = LedBrightness::Med; + } + // LampArray: a single HID feature report carries both colour and + // intensity, so we must push them together. Use write_effect_locked + // to avoid the try_lock fallback in write_effect that would clobber + // the colour with a Med/white default. Skip the subsequent + // set_brightness() call because set_brightness would race the + // colour we just wrote (try_lock fails -> white fallback push). + self.0.write_effect_locked(&config, &effect).await?; + config.set_builtin(effect); + config.write(); + Ok(()) + } + + /// Get the data set for every mode available + async fn all_mode_data(&self) -> BTreeMap { + let config = self.0.config.lock().await; + config.builtins.clone() + } + + #[zbus(property)] + async fn led_power(&self) -> LaptopAuraPower { + let config = self.0.config.lock().await; + config.enabled.clone() + } + + /// Set a variety of states, input is array of enum. + /// `enabled` sets if the sent array should be disabled or enabled. + #[zbus(property)] + async fn set_led_power(&mut self, options: LaptopAuraPower) -> Result<(), ZbErr> { + let mut config = self.0.config.lock().await; + for opt in options.states { + let zone = opt.zone; + for state in config.enabled.states.iter_mut() { + if state.zone == zone { + *state = opt; + break; + } + } + } + config.write(); + Ok(self.0.set_aura_power(&config).await.map_err(|e| { + warn!("{}", e); + e + })?) + } + + /// Direct addressing not supported on LampArray — the Microsoft HID + /// LampArray protocol has no per-key primitive on LampCount=1 devices. + /// Kept as a no-op so the interface signature matches [`AuraZbus`]. + async fn direct_addressing_raw(&self, _data: AuraLaptopUsbPackets) -> Result<(), ZbErr> { + debug!("LampArray: direct_addressing_raw ignored (no per-key primitive)"); + Ok(()) + } +} + +impl CtrlTask for LampArrayZbus { + fn zbus_path() -> &'static str { + "/xyz/ljones" + } + + async fn create_tasks(&self, _: SignalEmitter<'static>) -> Result<(), RogError> { + let inner_sleep = self.0.clone(); + let inner_shutdown = self.0.clone(); + self.create_sys_event_tasks( + move |sleeping| { + let inner = inner_sleep.clone(); + async move { + if !sleeping { + info!("LampArray CtrlKbdLedTask: reloading brightness and modes"); + let mut config = inner.config.lock().await; + inner + .write_current_config_mode(&mut config) + .await + .map_err(|e| { + error!("LampArray CtrlKbdLedTask: {e}"); + e + }) + .unwrap(); + } + } + }, + move |_shutting_down| { + let inner = inner_shutdown.clone(); + async move { + // Nothing to persist beyond config on shutdown for + // LampArray — brightness lives in config only. + let _ = inner; + } + }, + move |_lid_closed| async move {}, + move |_power_plugged| async move {}, + ) + .await; + Ok(()) + } +} + +impl Reloadable for LampArrayZbus { + async fn reload(&mut self) -> Result<(), RogError> { + debug!("reloading LampArray keyboard mode"); + let mut config = self.0.lock_config().await; + self.0.write_current_config_mode(&mut config).await?; + debug!("reloading LampArray power states"); + self.0 + .set_aura_power(&config) + .await + .map_err(|err| warn!("{err}")) + .ok(); + Ok(()) + } +} diff --git a/asusd/src/lib.rs b/asusd/src/lib.rs index 19d0549a2..da4840e1b 100644 --- a/asusd/src/lib.rs +++ b/asusd/src/lib.rs @@ -12,6 +12,7 @@ pub mod ctrl_xgm_led; pub mod asus_armoury; pub mod aura_anime; +pub mod aura_lamparray; pub mod aura_laptop; pub mod aura_manager; pub mod aura_scsi; From e124c7699dc1fcfedcd3eea39d800ba7c2b4e3e3 Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Mon, 13 Jul 2026 10:47:26 +0200 Subject: [PATCH 3/4] feat(asusd): integrate LampArray into DeviceHandle and DeviceManager --- asusd/src/aura_manager.rs | 168 +++++++++++++++++++++++++++++++++++++- asusd/src/aura_types.rs | 48 +++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/asusd/src/aura_manager.rs b/asusd/src/aura_manager.rs index 6d7fe524c..03d53c1ca 100644 --- a/asusd/src/aura_manager.rs +++ b/asusd/src/aura_manager.rs @@ -18,6 +18,7 @@ use zbus::zvariant::{ObjectPath, OwnedObjectPath}; use zbus::Connection; use crate::aura_anime::trait_impls::AniMeZbus; +use crate::aura_lamparray::trait_impls::LampArrayZbus; use crate::aura_laptop::trait_impls::AuraZbus; use crate::aura_scsi::trait_impls::ScsiZbus; use crate::aura_slash::trait_impls::SlashZbus; @@ -127,6 +128,12 @@ impl DeviceManager { handles: Arc>>>>, ) -> Result, RogError> { let mut devices = Vec::new(); + let sysname_dbg = device.sysname().to_string_lossy().to_string(); + let devnode_dbg = device + .devnode() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "".to_string()); + info!("init_hid_devices: probing hidraw sysname={sysname_dbg} devnode={devnode_dbg}"); if let Some(usb_device) = device.parent_with_subsystem_devtype("usb", "usb_device")? { if let Some(usb_id) = usb_device.attribute_value("idProduct") { if let Some(vendor_id) = usb_device.attribute_value("idVendor") { @@ -206,6 +213,156 @@ impl DeviceManager { } } } + if devices.is_empty() { + info!( + "init_hid_devices: no USB-side device matched for {sysname_dbg}, trying I2C-HID fallback" + ); + match Self::init_i2c_hid_device(connection, &device, handles.clone()).await { + Ok(mut found) => { + info!( + "init_hid_devices: I2C-HID fallback for {sysname_dbg} returned {} device(s)", + found.len() + ); + devices.append(&mut found); + } + Err(e) => { + error!("init_hid_devices: I2C-HID fallback for {sysname_dbg} failed: {e:?}"); + } + } + } + Ok(devices) + } + + async fn init_i2c_hid_device( + connection: &Connection, + endpoint: &Device, + handles: Arc>>>>, + ) -> Result, RogError> { + let mut devices = Vec::new(); + let sysname = endpoint.sysname().to_string_lossy().to_string(); + info!("I2C-HID probe: examining hidraw {sysname}"); + let mut hid_id: Option = None; + let mut cur = endpoint.parent(); + while let Some(p) = cur { + if let Some(val) = p.property_value("HID_ID") { + hid_id = Some(val.to_string_lossy().to_string()); + break; + } + cur = p.parent(); + } + let Some(hid_id) = hid_id else { + info!("I2C-HID probe: no HID_ID property found for {sysname}"); + return Ok(devices); + }; + info!("I2C-HID probe: {sysname} HID_ID={hid_id}"); + let parts: Vec<&str> = hid_id.split(':').collect(); + if parts.len() != 3 { + info!("I2C-HID probe: HID_ID has unexpected shape: {hid_id}"); + return Ok(devices); + } + // The HID_ID format is "BUS:VENDOR:PRODUCT" where VENDOR and PRODUCT are + // 8-char hex strings (e.g. "0018:00000B05:000019B6"). We must parse them + // numerically — a naive string compare against "0B05" fails because the + // actual VID string is "00000B05". + let vendor_str = parts[1]; + let product_str = parts[2]; + let vendor = u32::from_str_radix(vendor_str, 16).unwrap_or(0xFFFF_FFFF); + let product = u32::from_str_radix(product_str, 16).unwrap_or(0xFFFF_FFFF); + info!( + "I2C-HID probe: VID parsing raw='{vendor_str}' parsed=0x{vendor:08x}; PID parsing raw='{product_str}' parsed=0x{product:08x}" + ); + if vendor != 0x0b05 { + info!( + "I2C-HID probe: not ASUS vendor 0x{vendor:08x} (raw '{vendor_str}'), skipping {sysname}" + ); + return Ok(devices); + } + let vid_upper = format!("{:04X}", vendor as u16); + let pid_upper = format!("{:04X}", product as u16); + let prod_id_str = pid_upper.to_lowercase(); + info!("Found ASUS HID LampArray candidate: {sysname} VID={vid_upper} PID={pid_upper}"); + info!("VID match: proceeding to open hidraw for {sysname}"); + info!("Step1: about to query devnode for {sysname}"); + let dev_node = match endpoint.devnode() { + Some(n) => n, + None => { + error!("I2C-HID probe: hidraw devnode missing for {sysname}"); + return Err(RogError::MissingFunction( + "I2C-HID hidraw devnode missing".to_string(), + )); + } + }; + info!("Step2: devnode={dev_node:?} for {sysname}"); + let key = dev_node.to_string_lossy().to_string(); + info!("Step3a: about to take handles map lock for cache lookup key={key}"); + let cached = handles.lock().await.get(&key).cloned(); + info!( + "Step3b: handles map lock released for {key} cached={}", + cached.is_some() + ); + let handle = if let Some(existing) = cached { + info!("I2C-HID probe: reusing existing hidraw handle for {key}"); + existing + } else { + info!("Step3c: about to call HidRaw::from_i2c_device for {key} prod_id={prod_id_str}"); + // udev::Device contains a raw `*mut udev` and is !Send, so we + // cannot ship it into spawn_blocking. Instead we call the + // synchronous constructor here — the actual blocking syscall is + // the OpenOptions::open in from_i2c_device, and we mitigate that + // separately by passing O_NONBLOCK there. + info!("Step3d: calling HidRaw::from_i2c_device synchronously for {key}"); + let hidraw_res = HidRaw::from_i2c_device(endpoint.clone(), &prod_id_str); + let hidraw = match hidraw_res { + Ok(h) => h, + Err(e) => { + error!("I2C-HID probe: HidRaw::from_i2c_device FAILED for {key}: {e:?}"); + return Err(e.into()); + } + }; + info!("Step4: hidraw handle created for {key}"); + let h = Arc::new(Mutex::new(hidraw)); + info!("Step4b: about to insert handle into handles map key={key}"); + handles.lock().await.insert(key.clone(), h.clone()); + info!("Step4c: handle inserted into handles map key={key}"); + h + }; + info!( + "Step5: about to call DeviceHandle::maybe_lamparray for {sysname} prod_id={prod_id_str}" + ); + info!("Calling DeviceHandle::maybe_lamparray for {sysname} prod_id={prod_id_str}"); + let result = DeviceHandle::maybe_lamparray(handle, &prod_id_str).await; + info!("Step6: maybe_lamparray returned for {sysname}"); + match result { + Ok(dev_type) => { + info!("maybe_lamparray OK for {sysname}"); + if let DeviceHandle::LampArray(lamparray) = dev_type.clone() { + let path: OwnedObjectPath = ObjectPath::from_str_unchecked(&format!( + "{ASUS_ZBUS_PATH}/{MOD_NAME}/lamparray_{prod_id_str}" + )) + .into(); + info!("Registering LampArray device on zbus at path={path:?}"); + let ctrl = LampArrayZbus::new(lamparray); + match ctrl.start_tasks(connection, path.clone()).await { + Ok(_) => info!("LampArray zbus start_tasks OK for {path:?}"), + Err(e) => { + error!("LampArray zbus start_tasks FAILED for {path:?}: {e:?}"); + return Ok(devices); + } + } + devices.push(AsusDevice { + device: dev_type, + dbus_path: path.clone(), + hid_key: Some(key), + }); + info!("LampArray device added to manager at {path:?}"); + } else { + info!("maybe_lamparray returned non-LampArray variant for {sysname}, ignoring"); + } + } + Err(e) => { + error!("maybe_lamparray FAILED for {sysname}: {e:?}"); + } + } Ok(devices) } @@ -342,7 +499,10 @@ 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(_) | DeviceHandle::OldAura(_) | DeviceHandle::LampArray(_) + ) { do_kb_backlight = false; } } @@ -559,6 +719,12 @@ impl DeviceManager { .remove::(&path) .await? } + DeviceHandle::LampArray(_) => { + conn_copy + .object_server() + .remove::(&path) + .await? + } DeviceHandle::Slash(_) => { conn_copy .object_server() diff --git a/asusd/src/aura_types.rs b/asusd/src/aura_types.rs index 6c255cfd3..3d49fe859 100644 --- a/asusd/src/aura_types.rs +++ b/asusd/src/aura_types.rs @@ -16,6 +16,7 @@ use tokio::sync::Mutex; use crate::aura_anime::config::AniMeConfig; use crate::aura_anime::AniMe; +use crate::aura_lamparray::LampArray; use crate::aura_laptop::config::AuraConfig; use crate::aura_laptop::Aura; use crate::aura_scsi::config::ScsiConfig; @@ -37,6 +38,7 @@ pub enum _DeviceHandle { #[derive(Clone)] pub enum DeviceHandle { Aura(Aura), + LampArray(LampArray), Slash(Slash), /// The AniMe devices require USBRaw as they are not HID devices AniMe(AniMe), @@ -208,4 +210,50 @@ impl DeviceHandle { aura.do_initialization().await?; Ok(Self::Aura(aura)) } + + /// Try the HID LampArray (Microsoft HID LampArray usage page) path used by + /// I2C-HID controllers on newer ASUS TUF laptops (e.g. FA608WV / ITE5570). + /// We open the hidraw R/W, run HIDIOCGRAWINFO and only proceed if the + /// device declares ASUS VID 0x0b05 and a whitelisted LampArray PID. + pub async fn maybe_lamparray( + device: Arc>, + prod_id: &str, + ) -> Result { + let dev_path = { + let g = device.lock().await; + g.devfs_path().clone() + }; + info!("LampArray init: device {dev_path:?} prod_id={prod_id:?}"); + info!("LampArray init: about to call raw_info on {dev_path:?}"); + let info_res = { + let g = device.lock().await; + g.raw_info() + }; + let info = match info_res { + Ok(i) => i, + Err(e) => { + error!("LampArray init: raw_info FAILED on {dev_path:?}: {e:?}"); + return Err(RogError::Platform(e)); + } + }; + let vid = (info.vendor as u16) as u32; + let pid = (info.product as u16) as u32; + info!("LampArray init: HIDIOCGRAWINFO {dev_path:?} VID:{vid:04x} PID:{pid:04x}"); + if vid != 0x0b05 { + return Err(RogError::NotFound(format!("Not ASUS: {vid:04x}"))); + } + if !matches!(pid, 0x19b6) { + return Err(RogError::NotFound(format!( + "PID {pid:04x} not on LampArray whitelist" + ))); + } + let aura_type = AuraDeviceType::from(prod_id); + info!("Found HID LampArray ASUS keyboard 0b05:{pid:04x}"); + let mut config = AuraConfig::load_and_update_config(prod_id); + config.led_type = aura_type; + let lamparray = LampArray::new(device, Arc::new(Mutex::new(config))); + lamparray.do_initialization().await?; + info!("LampArray ready: 0b05:{pid:04x} on {dev_path:?}"); + Ok(Self::LampArray(lamparray)) + } } From f97af4992e71c6babf271f7c545bb543b5a3dedc Mon Sep 17 00:00:00 2001 From: Marco Scardovi Date: Mon, 13 Jul 2026 10:47:40 +0200 Subject: [PATCH 4/4] refactor(asusd): request D-Bus name before starting DeviceManager enumeration --- asusd/src/daemon.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/asusd/src/daemon.rs b/asusd/src/daemon.rs index 64f2da993..8336d972c 100644 --- a/asusd/src/daemon.rs +++ b/asusd/src/daemon.rs @@ -150,9 +150,27 @@ async fn start_daemon() -> Result<(), Box> { } } - let _ = DeviceManager::new(server.clone()).await?; + // Request the well-known dbus name BEFORE we go enumerate hidraw nodes. + // systemd's `Type=dbus` ready signal is the appearance of this name on + // the bus; if we delay it behind device probing (which on FA608WV + // includes a LampArray HID conversation that can stall on a kernel + // hidraw read), systemd kills us with a TimeoutSec=10 timeout and the + // service ends up reported as ServiceUnknown / "not activatable". + // We can still register additional zbus objects after request_name; the + // object server accepts new paths at any time. + server.request_name(DBUS_NAME).await?; + + info!("Startup ready on dbus name {DBUS_NAME}: hidraw enumeration runs in background"); - info!("DeviceManager initialized"); + // Now do the hidraw enumeration. The well-known name is already on the + // bus so systemd considers us ready, and if a device probe stalls it + // cannot trigger the systemd start timeout. (We can't easily push this + // off into a `tokio::spawn` because `udev::Enumerator` and friends are + // not `Send`.) + match DeviceManager::new(server.clone()).await { + Ok(_) => info!("DeviceManager initialized"), + Err(e) => error!("DeviceManager init failed: {e:?}"), + } // XG Mobile LED (non-fatal if not attached) match CtrlXgmLed::try_new(config.clone()) { @@ -179,10 +197,7 @@ async fn start_daemon() -> Result<(), Box> { info!("CtrlGpu: initialized"); } - // Request dbus name after finishing initalizing all functions - server.request_name(DBUS_NAME).await?; - - info!("Startup success on dbus name {DBUS_NAME}: begining dbus server loop"); + info!("Entering dbus server loop"); loop { // This is just a blocker to idle and ensure the reator reacts server.executor().tick().await;