diff --git a/Cargo.lock b/Cargo.lock index 2d9d73cc..d4dfbe2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4757,6 +4757,7 @@ version = "6.3.8" dependencies = [ "concat-idents", "inotify", + "libc", "log", "rusb", "serde", diff --git a/asusd/src/aura_lamparray/effects.rs b/asusd/src/aura_lamparray/effects.rs new file mode 100644 index 00000000..96f712a2 --- /dev/null +++ b/asusd/src/aura_lamparray/effects.rs @@ -0,0 +1,183 @@ +//! 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; + +/// info! only in debug builds — release stays quiet. +macro_rules! debug_info { + ($($arg:tt)*) => {{ + if cfg!(debug_assertions) { + log::info!($($arg)*); + } + }}; +} + +/// 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; + } + debug_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 00000000..c79d278a --- /dev/null +++ b/asusd/src/aura_lamparray/mod.rs @@ -0,0 +1,353 @@ +//! `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; + +/// info! only in debug builds — release stays quiet. +/// +/// Wraps the low-level per-frame / per-step trace so `cargo build --release` +/// produces a journal that only contains lifecycle events (device +/// discovery, effect spawn/cancel, write_effect_and_apply). +macro_rules! debug_info { + ($($arg:tt)*) => {{ + if cfg!(debug_assertions) { + log::info!($($arg)*); + } + }}; +} + +#[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 { + debug_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(_) => { + debug_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; + debug_info!("lamparray_write_effect: Static, single push"); + debug_info!( + "lamparray_write_effect_locked: about to push rgb (caller owns config lock)" + ); + self.push_rgb_i(r, g, b, intensity).await + } + _ => { + debug_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; + debug_info!("lamparray_write_effect_locked: Static, single push"); + debug_info!( + "lamparray_write_effect_locked: about to push rgb (caller owns config lock)" + ); + self.push_rgb_i(r, g, b, intensity).await + } + _ => { + debug_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(_) => { + debug_info!( + "lamparray_set_brightness: config already locked by caller, defaulting to white" + ); + (0xff, 0xff, 0xff) + } + }; + debug_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 00000000..f6ba7ce8 --- /dev/null +++ b/asusd/src/aura_lamparray/trait_impls.rs @@ -0,0 +1,281 @@ +//! 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/aura_laptop/mod.rs b/asusd/src/aura_laptop/mod.rs index bc053df6..00aa802f 100644 --- a/asusd/src/aura_laptop/mod.rs +++ b/asusd/src/aura_laptop/mod.rs @@ -5,7 +5,7 @@ use config_traits::StdConfig; use log::info; use rog_aura::keyboard::{AuraLaptopUsbPackets, LedUsbPackets}; use rog_aura::usb::{AURA_LAPTOP_LED_APPLY, AURA_LAPTOP_LED_SET}; -use rog_aura::{AuraDeviceType, AuraEffect, LedBrightness, PowerZones, AURA_LAPTOP_LED_MSG_LEN}; +use rog_aura::{AuraDeviceType, AuraEffect, PowerZones, AURA_LAPTOP_LED_MSG_LEN, LedBrightness}; use rog_platform::hid_raw::HidRaw; use rog_platform::keyboard_led::KeyboardBacklight; use tokio::sync::{Mutex, MutexGuard}; @@ -15,6 +15,12 @@ use crate::error::RogError; pub mod config; pub mod trait_impls; +/// Aura keyboard controller for USB/asus-wmi path. +/// +/// This is the legacy path: HID over USB with the ASUS proprietary aura +/// protocol, or TUF sysfs backlight. LampArray devices (HID Usage Page 0x59 +/// on I2C-HID) live in the sibling module `aura_lamparray` and no longer +/// pollute this struct with `is_lamparray` branches. #[derive(Debug, Clone)] pub struct Aura { pub hid: Option>>, @@ -65,7 +71,6 @@ impl Aura { 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() { @@ -80,7 +85,6 @@ impl Aura { .await?; } } - Ok(()) } @@ -123,7 +127,6 @@ impl Aura { } else { return Err(RogError::NoAuraKeyboard); } - Ok(()) } @@ -163,7 +166,6 @@ impl Aura { return Ok(()); } } - let bytes = config.enabled.to_bytes(config.led_type); let msg = [ 0x5d, 0xbd, 0x01, bytes[0], bytes[1], bytes[2], bytes[3], @@ -185,10 +187,8 @@ impl Aura { config.brightness = LedBrightness::Med; config.write(); } - let pkt_type = effect[0][1]; const PER_KEY_TYPE: u8 = 0xbc; - if let Some(hid_raw) = &self.hid { let hid_raw = hid_raw.lock().await; if pkt_type != PER_KEY_TYPE { diff --git a/asusd/src/aura_laptop/trait_impls.rs b/asusd/src/aura_laptop/trait_impls.rs index 8a4daa66..7103e5f8 100644 --- a/asusd/src/aura_laptop/trait_impls.rs +++ b/asusd/src/aura_laptop/trait_impls.rs @@ -115,9 +115,6 @@ impl AuraZbus { /// The current mode data #[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 { @@ -133,10 +130,10 @@ impl AuraZbus { async fn set_led_mode(&mut self, num: AuraModeNum) -> Result<(), ZbErr> { let mut config = self.0.config.lock().await; config.current_mode = num; - self.0.write_current_config_mode(&mut config).await?; if config.brightness == LedBrightness::Off { config.brightness = LedBrightness::Med; } + self.0.write_current_config_mode(&mut config).await?; self.0.set_brightness(config.brightness.into()).await?; config.write(); Ok(()) @@ -145,7 +142,6 @@ impl AuraZbus { /// The current mode data #[zbus(property)] async fn led_mode_data(&self) -> Result { - // entirely possible to deadlock here, so use try instead of lock() if let Ok(config) = self.0.config.try_lock() { let mode = config.current_mode; match config.builtins.get(&mode) { @@ -172,13 +168,12 @@ impl AuraZbus { "The Aura effect is not supported: {effect:?}" ))); } - - self.0 - .write_effect_and_apply(config.led_type, &effect) - .await?; if config.brightness == LedBrightness::Off { config.brightness = LedBrightness::Med; } + self.0 + .write_effect_and_apply(config.led_type, &effect) + .await?; self.0.set_brightness(config.brightness.into()).await?; config.set_builtin(effect); config.write(); @@ -306,28 +301,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 6d7fe524..d523bfd9 100644 --- a/asusd/src/aura_manager.rs +++ b/asusd/src/aura_manager.rs @@ -3,7 +3,6 @@ // - If a device is found, add it to watch // - Add it to Zbus server // - If udev sees device removed then remove the zbus path - use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -18,6 +17,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; @@ -27,6 +27,15 @@ use crate::ASUS_ZBUS_PATH; const MOD_NAME: &str = "aura"; +/// info! only in debug builds — release stays quiet. +macro_rules! debug_info { + ($($arg:tt)*) => {{ + if cfg!(debug_assertions) { + log::info!($($arg)*); + } + }}; +} + /// Returns only the Device details concatenated in a form usable for /// adding/appending to a filename pub fn filename_partial(parent: &Device) -> Option { @@ -110,11 +119,9 @@ impl DeviceManager { .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()); @@ -127,6 +134,15 @@ 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()); + debug_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 +222,168 @@ impl DeviceManager { } } } + if devices.is_empty() { + debug_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) => { + debug_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(); + debug_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 { + debug_info!("I2C-HID probe: no HID_ID property found for {sysname}"); + return Ok(devices); + }; + debug_info!("I2C-HID probe: {sysname} HID_ID={hid_id}"); + let parts: Vec<&str> = hid_id.split(':').collect(); + if parts.len() != 3 { + debug_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); + debug_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 { + debug_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}" + ); + debug_info!("VID match: proceeding to open hidraw for {sysname}"); + debug_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(), + )); + } + }; + debug_info!("Step2: devnode={dev_node:?} for {sysname}"); + let key = dev_node.to_string_lossy().to_string(); + debug_info!("Step3a: about to take handles map lock for cache lookup key={key}"); + let cached = handles.lock().await.get(&key).cloned(); + debug_info!( + "Step3b: handles map lock released for {key} cached={}", + cached.is_some() + ); + let handle = if let Some(existing) = cached { + debug_info!("I2C-HID probe: reusing existing hidraw handle for {key}"); + existing + } else { + debug_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. + debug_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()); + } + }; + debug_info!("Step4: hidraw handle created for {key}"); + let h = Arc::new(Mutex::new(hidraw)); + debug_info!("Step4b: about to insert handle into handles map key={key}"); + handles.lock().await.insert(key.clone(), h.clone()); + debug_info!("Step4c: handle inserted into handles map key={key}"); + h + }; + debug_info!( + "Step5: about to call DeviceHandle::maybe_lamparray for {sysname} prod_id={prod_id_str}" + ); + debug_info!("Calling DeviceHandle::maybe_lamparray for {sysname} prod_id={prod_id_str}"); + let result = DeviceHandle::maybe_lamparray(handle, &prod_id_str).await; + debug_info!("Step6: maybe_lamparray returned for {sysname}"); + match result { + Ok(dev_type) => { + debug_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(_) => debug_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), + }); + debug_info!("LampArray device added to manager at {path:?}"); + } else { + debug_info!( + "maybe_lamparray returned non-LampArray variant for {sysname}, ignoring" + ); + } + } + Err(e) => { + error!("maybe_lamparray FAILED for {sysname}: {e:?}"); + } + } Ok(devices) } @@ -220,17 +398,15 @@ impl DeviceManager { // 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(); - let mut enumerator = udev::Enumerator::new().map_err(|err| { warn!("{}", err); PlatformError::Udev("enumerator failed".into(), err) })?; - enumerator.match_subsystem("hidraw").map_err(|err| { warn!("{}", err); PlatformError::Udev("match_subsystem failed".into(), err) })?; - + debug_info!("LampArray initial enumeration: scanning hidraw devices"); for device in enumerator .scan_devices() .map_err(|e| PlatformError::IoPath("enumerator".to_owned(), e))? @@ -248,7 +424,6 @@ impl DeviceManager { } devices.append(&mut Self::init_hid_devices(connection, device, handles.clone()).await?); } - Ok(devices) } @@ -287,17 +462,14 @@ impl DeviceManager { // track and ensure we use only one hidraw per prod_id // let mut interfaces = HashSet::new(); let mut devices: Vec = Vec::new(); - let mut enumerator = udev::Enumerator::new().map_err(|err| { warn!("{}", err); PlatformError::Udev("enumerator failed".into(), err) })?; - enumerator.match_subsystem("block").map_err(|err| { warn!("{}", err); PlatformError::Udev("match_subsystem failed".into(), err) })?; - let mut found = Vec::new(); for device in enumerator .scan_devices() @@ -309,7 +481,6 @@ impl DeviceManager { if found.contains(&path) { continue; } - if let Some(dev) = Self::init_scsi(connection, &device, path.clone()).await { devices.push(dev); found.push(path); @@ -318,7 +489,6 @@ impl DeviceManager { debug!("No serial for SCSI device: {:?}", device.devpath()); } } - Ok(devices) } @@ -342,11 +512,13 @@ 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; } } - if do_slash { if let Ok(dev_type) = DeviceHandle::new_slash_usb().await { if let DeviceHandle::Slash(slash) = dev_type.clone() { @@ -363,7 +535,6 @@ impl DeviceManager { info!("Tested device was not Slash"); } } - if do_anime { if let Ok(dev_type) = DeviceHandle::maybe_anime_usb().await { // TODO: this is copy/pasted @@ -387,7 +558,6 @@ impl DeviceManager { info!("Tested device was not AniMe Matrix"); } } - if do_kb_backlight { // TUF AURA LAPTOP DEVICE // product_name = ASUS TUF Gaming F15 FX507ZE_FX507ZE @@ -413,11 +583,9 @@ impl DeviceManager { } } } - if let Ok(devs) = &mut Self::init_all_scsi(connection).await { devices.append(devs); } - devices } @@ -431,10 +599,8 @@ impl DeviceManager { _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()?; @@ -442,7 +608,6 @@ impl DeviceManager { let mut events = Events::with_capacity(1024); poll.registry() .register(&mut monitor, Token(0), Interest::READABLE)?; - let rt = tokio::runtime::Runtime::new().expect("Unable to create Runtime"); let _enter = rt.enter(); loop { @@ -455,13 +620,11 @@ impl DeviceManager { .unwrap_or_default() .to_string_lossy() .to_string(); - let subsys = if let Some(subsys) = event.subsystem() { subsys.to_string_lossy().to_string() } else { continue; }; - let devices = devices.clone(); let conn_copy = conn_copy.clone(); let hid_handles = hid_handles_thread.clone(); @@ -474,7 +637,6 @@ impl DeviceManager { { let serial = serial.to_string_lossy().to_string(); let path = dbus_path_for_scsi(&serial); - let index = if let Some(index) = devices .lock() .await @@ -512,7 +674,6 @@ impl DeviceManager { } }; } - if subsys == "hidraw" { if action == "remove" { // Key cleanup off the removed hidraw node itself, NOT the @@ -559,6 +720,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 6c255cfd..b304c20d 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; @@ -24,6 +25,15 @@ use crate::aura_slash::config::SlashConfig; use crate::aura_slash::Slash; use crate::error::RogError; +/// info! only in debug builds — release stays quiet. +macro_rules! debug_info { + ($($arg:tt)*) => {{ + if cfg!(debug_assertions) { + log::info!($($arg)*); + } + }}; +} + pub enum _DeviceHandle { /// The AniMe devices require USBRaw as they are not HID devices Usb(USBRaw), @@ -37,6 +47,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), @@ -69,7 +80,6 @@ impl DeviceHandle { return Err(RogError::NotFound("No slash device".to_string())); } info!("Found slash type {slash_type:?}: {prod_id}"); - let mut config = SlashConfig::new().load(); config.slash_type = slash_type; let slash = Slash::new(Some(device), None, Arc::new(Mutex::new(config))); @@ -84,10 +94,8 @@ impl DeviceHandle { 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( @@ -107,25 +115,9 @@ impl DeviceHandle { _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 { @@ -135,10 +127,8 @@ impl DeviceHandle { info!("No Anime Matrix capable laptop found"); return Err(RogError::Anime(AnimeError::NoDevice)); } - if let Ok(usb) = USBRaw::new(0x193b) { info!("Found AniMe Matrix USB {anime_type:?}"); - let mut config = AniMeConfig::new().load(); config.anime_type = anime_type; let mut anime = AniMe::new( @@ -163,7 +153,6 @@ impl DeviceHandle { return Err(RogError::NotFound("No SCSI device".to_string())); } info!("Found SCSI device {prod_id:?} on {dev_node}"); - let mut config = ScsiConfig::new().load(); config.dev_type = AuraDeviceType::ScsiExtDisk; let dev = Arc::new(Mutex::new(open_device(dev_node)?)); @@ -189,14 +178,12 @@ impl DeviceHandle { return Err(RogError::NotFound("No laptop aura device".to_string())); } info!("Found laptop aura type {prod_id:?}"); - let backlight = KeyboardBacklight::new() .map_err(|e| error!("Keyboard backlight error: {e:?}")) .map_or(None, |k| { info!("Found sysfs backlight control"); Some(Arc::new(Mutex::new(k))) }); - // 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; @@ -208,4 +195,52 @@ 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() + }; + debug_info!("LampArray init: device {dev_path:?} prod_id={prod_id:?}"); + debug_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; + debug_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)) + } } diff --git a/asusd/src/daemon.rs b/asusd/src/daemon.rs index 64f2da99..d3c7d2a9 100644 --- a/asusd/src/daemon.rs +++ b/asusd/src/daemon.rs @@ -150,10 +150,6 @@ async fn start_daemon() -> Result<(), Box> { } } - let _ = DeviceManager::new(server.clone()).await?; - - info!("DeviceManager initialized"); - // XG Mobile LED (non-fatal if not attached) match CtrlXgmLed::try_new(config.clone()) { Ok(Some(ctrl)) => { @@ -179,10 +175,29 @@ async fn start_daemon() -> Result<(), Box> { info!("CtrlGpu: initialized"); } - // Request dbus name after finishing initalizing all functions + // 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 success on dbus name {DBUS_NAME}: begining dbus server loop"); + info!("Startup ready on dbus name {DBUS_NAME}: hidraw enumeration runs in background"); + + // 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:?}"), + } + + info!("Entering dbus server loop"); loop { // This is just a blocker to idle and ensure the reator reacts server.executor().tick().await; diff --git a/asusd/src/lib.rs b/asusd/src/lib.rs index 19d0549a..da4840e1 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; diff --git a/rog-platform/Cargo.toml b/rog-platform/Cargo.toml index ed5a3e01..7fbdcfbb 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 +libc = "0.2" [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 abb349c6..d2d07d4d 100644 --- a/rog-platform/src/hid_raw.rs +++ b/rog-platform/src/hid_raw.rs @@ -1,13 +1,60 @@ use std::cell::RefCell; use std::fs::{File, OpenOptions}; use std::io::Write; +use std::os::unix::io::AsRawFd; +use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; - -use log::{info, warn}; +use libc; +use log::{error, info, warn}; use udev::Device; - use crate::error::{PlatformError, Result}; +/// info! only in debug builds — release stays quiet. +/// +/// Wraps the low-level per-step / per-syscall trace so `cargo build --release` +/// produces a journal that only contains lifecycle events. The identical +/// definition lives in `asusd/src/aura_lamparray/mod.rs` and friends; kept +/// duplicated because we don't want a shared debug-log crate for one macro. +macro_rules! debug_info { + ($($arg:tt)*) => {{ + if cfg!(debug_assertions) { + log::info!($($arg)*); + } + }}; +} + + +/// Matches the kernel `struct hidraw_devinfo` (8 bytes total). +#[repr(C)] +pub struct HidrawDevinfo { + pub bustype: u32, + pub vendor: i16, + pub product: i16, +} + +// ioctl number helpers (mirror the kernel macros _IOR / _IOWR) +const fn _ior(t: u32, nr: u32, size: u32) -> u32 { + (2u32 << 30) | (size << 16) | (t << 8) | nr +} +const fn _iowr(t: u32, nr: u32, size: u32) -> u32 { + (3u32 << 30) | (size << 16) | (t << 8) | nr +} + +/// HIDIOCGRAWINFO: returns the `hidraw_devinfo` struct (8 bytes -> 0x80084803). +pub fn hidiocgrawinfo() -> u32 { + _ior(b'H' as u32, 0x03, 8) +} + +/// HIDIOCSFEATURE(len) +pub fn hidiocsfeature(size: usize) -> u32 { + _iowr(b'H' as u32, 0x06, size as u32) +} + +/// HIDIOCGFEATURE(len) +pub fn hidiocgfeature(size: usize) -> u32 { + _iowr(b'H' as u32, 0x07, size as u32) +} + /// A USB device that utilizes hidraw for I/O #[derive(Debug)] pub struct HidRaw { @@ -21,19 +68,16 @@ pub struct HidRaw { /// Retaining a handle to the file for the duration of `HidRaw` file: RefCell, } - impl HidRaw { pub fn new(id_product: &str) -> Result { let mut enumerator = udev::Enumerator::new().map_err(|err| { warn!("{}", err); PlatformError::Udev("enumerator failed".into(), err) })?; - enumerator.match_subsystem("hidraw").map_err(|err| { warn!("{}", err); PlatformError::Udev("match_subsystem failed".into(), err) })?; - for endpoint in enumerator .scan_devices() .map_err(|e| PlatformError::IoPath("enumerator".to_owned(), e))? @@ -77,7 +121,6 @@ impl HidRaw { id_product ))) } - /// Make `HidRaw` device from a udev device pub fn from_device(endpoint: Device) -> Result { if let Some(parent) = endpoint @@ -108,10 +151,66 @@ 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(); + debug_info!( + "HidRaw::from_i2c_device: begin sysname={} prod_id={}", + sysname_dbg, prod_id + ); + debug_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()) + })?; + debug_info!( + "HidRaw::from_i2c_device: devnode={:?} sysname={}", + dev_node, sysname_dbg + ); + debug_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(libc::O_NONBLOCK) + .open(dev_node) + .map_err(|e| PlatformError::IoPath(dev_node.to_string_lossy().to_string(), e))?; + let fd = file.as_raw_fd(); + debug_info!( + "HidRaw::from_i2c_device: file opened fd={} dev_node={:?}", + fd, dev_node + ); + debug_info!("HidRaw::from_i2c_device: about to query syspath for sysname={}", sysname_dbg); + let syspath = endpoint.syspath().to_path_buf(); + debug_info!( + "HidRaw::from_i2c_device: syspath={:?} sysname={}", + syspath, sysname_dbg + ); + debug_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() { @@ -122,11 +221,125 @@ impl HidRaw { } Ok(()) } - /// This method was added for certain devices like AniMe to prevent them /// waking the laptop pub fn set_wakeup_disabled(&self) -> Result<()> { 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(); + let req = hidiocgrawinfo(); + let mut info = HidrawDevinfo { + bustype: 0, + vendor: 0, + product: 0, + }; + debug_info!( + "HidRaw::raw_info: fd={} ioctl=0x{:08x} struct_size={}", + fd, + req, + std::mem::size_of::() + ); + // SAFETY: We pass a pointer to a 8-byte struct matching the kernel's + // hidraw_devinfo layout; the ioctl number encodes that size. + let ret = unsafe { + libc::ioctl( + fd, + req as libc::c_ulong, + &mut info as *mut HidrawDevinfo as *mut libc::c_void, + ) + }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + error!( + "HidRaw::raw_info: ioctl HIDIOCGRAWINFO failed on {:?}: {}", + self.devfs_path, err + ); + return Err(PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + )); + } + debug_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 fd = file.as_raw_fd(); + let req = hidiocsfeature(payload.len()); + // SAFETY: ioctl number encodes the buffer length, and we pass a pointer + // to a contiguous buffer of exactly that length. + let ret = unsafe { + libc::ioctl( + fd, + req as libc::c_ulong, + payload.as_ptr() as *mut libc::c_void, + ) + }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + error!( + "HidRaw::set_feature_report: ioctl HIDIOCSFEATURE(len={}) failed on {:?}: {}", + payload.len(), + self.devfs_path, + err + ); + return Err(PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + )); + } + 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 fd = file.as_raw_fd(); + let req = hidiocgfeature(buf.len()); + // SAFETY: ioctl number encodes the buffer length, and we pass a pointer + // to a contiguous buffer of exactly that length. + let ret = unsafe { + libc::ioctl( + fd, + req as libc::c_ulong, + buf.as_mut_ptr() as *mut libc::c_void, + ) + }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + error!( + "HidRaw::get_feature_report: ioctl HIDIOCGFEATURE(len={}) failed on {:?}: {}", + buf.len(), + self.devfs_path, + err + ); + return Err(PlatformError::IoPath( + self.devfs_path.to_string_lossy().to_string(), + err, + )); + } + Ok(ret as usize) + } }