From b360748b828bf17f49cc9354b477ca673749c867 Mon Sep 17 00:00:00 2001 From: Admin Pupkin Date: Wed, 20 May 2026 17:47:39 +0300 Subject: [PATCH] base: Add ACPI fan device discovery and status exposure (P48) Add fan.rs module to acpid that discovers FAN* devices under \_TZ, evaluates _FST for current speed level and RPM, and exposes them via /scheme/acpi/fan//status. Update thermald to read and log fan status alongside temperature sensors. --- .../patches/base/P48-acpid-fan-support.patch | 324 ++++++++++++++++++ recipes/core/base/recipe.toml | 2 + 2 files changed, 326 insertions(+) create mode 100644 local/patches/base/P48-acpid-fan-support.patch diff --git a/local/patches/base/P48-acpid-fan-support.patch b/local/patches/base/P48-acpid-fan-support.patch new file mode 100644 index 0000000000..4a857b916a --- /dev/null +++ b/local/patches/base/P48-acpid-fan-support.patch @@ -0,0 +1,324 @@ +diff --git a/drivers/acpid/src/acpi.rs b/drivers/acpid/src/acpi.rs +index 5c881334..ea480bb7 100644 +--- a/drivers/acpid/src/acpi.rs ++++ b/drivers/acpid/src/acpi.rs +@@ -452,0 +453 @@ pub struct AcpiContext { ++ pub fan_state: crate::fan::FanState, +@@ -533,0 +535 @@ impl AcpiContext { ++ fan_state: crate::fan::FanState::new(), +@@ -564 +566 @@ impl AcpiContext { +- // Discover thermal zones if AML is ready. ++ // Discover thermal zones and fan devices if AML is ready. +@@ -565,0 +568 @@ impl AcpiContext { ++ this.fan_state.refresh(&this); +@@ -663,0 +667,24 @@ impl AcpiContext { ++ /// Discover fan device names by scanning the AML namespace under `\_TZ`. ++ pub fn fan_device_names(&self) -> Result, AmlEvalError> { ++ let mut symbols = self.aml_symbols.write(); ++ let interpreter = symbols.aml_context_mut()?; ++ let mut ns = interpreter.namespace.lock(); ++ ++ let mut names = Vec::new(); ++ let _ = ns.traverse(|level_aml_name, _level| { ++ let name_str = aml_to_symbol(level_aml_name); ++ if name_str.starts_with("\\_TZ_.FAN") || name_str.starts_with("_TZ_.FAN") { ++ let after_prefix = if name_str.starts_with("\\_TZ_.") { ++ &name_str[7..] ++ } else { ++ &name_str[6..] ++ }; ++ if !after_prefix.contains('.') { ++ names.push(after_prefix.to_string()); ++ } ++ } ++ Ok(true) ++ }); ++ Ok(names) ++ } ++ +diff --git a/drivers/acpid/src/fan.rs b/drivers/acpid/src/fan.rs +new file mode 100644 +index 00000000..8b4fd533 +--- /dev/null ++++ b/drivers/acpid/src/fan.rs +@@ -0,0 +1,177 @@ ++use acpi::aml::namespace::AmlName; ++use acpi::aml::AmlError; ++use std::str::FromStr; ++use std::sync::{Arc, RwLock}; ++ ++use crate::acpi::{AcpiContext, AmlEvalError}; ++use amlserde::AmlSerdeValue; ++ ++/// A discovered ACPI fan device. ++#[derive(Clone, Debug)] ++pub struct FanDevice { ++ pub name: String, ++ /// Current speed level from _FST (0 = off, higher = faster). ++ pub current_level: Option, ++ /// Current speed in RPM from _FST (0xFFFFFFFF = unknown). ++ pub current_rpm: Option, ++} ++ ++impl FanDevice { ++ fn from_device_eval( ++ ctx: &AcpiContext, ++ device_name: &str, ++ ) -> Result { ++ let aml_prefix = format!("\\_TZ_.{device_name}"); ++ ++ let mut fan = FanDevice { ++ name: device_name.to_owned(), ++ current_level: None, ++ current_rpm: None, ++ }; ++ ++ // Evaluate _FST (fan status). ACPI spec: returns a package: ++ // { Revision (Integer), CurrentSpeedLevel (Integer), CurrentSpeedRPM (Integer) } ++ if let Ok(fst_name) = AmlName::from_str(&format!("{aml_prefix}._FST")) { ++ match ctx.aml_eval(fst_name, Vec::new()) { ++ Ok(value) => { ++ if let AmlSerdeValue::Package(elements) = value { ++ if elements.len() >= 2 { ++ fan.current_level = extract_u64(&elements[1]); ++ } ++ if elements.len() >= 3 { ++ fan.current_rpm = extract_u64(&elements[2]); ++ } ++ } ++ } ++ Err(e) => { ++ log::debug!("Fan device {device_name}: _FST eval failed: {e:?}"); ++ } ++ } ++ } ++ ++ Ok(fan) ++ } ++ ++ /// Produce a text summary suitable for scheme read(). ++ pub fn to_text(&self) -> String { ++ let mut s = String::new(); ++ s.push_str(&format!("name={}\n", self.name)); ++ s.push_str(&format!( ++ "current_level={}\n", ++ format_option_u64(self.current_level) ++ )); ++ s.push_str(&format!( ++ "current_rpm={}\n", ++ format_option_u64(self.current_rpm) ++ )); ++ s ++ } ++} ++ ++fn format_option_u64(value: Option) -> String { ++ match value { ++ Some(v) => { ++ if v == 0xFFFFFFFF { ++ "unknown".to_string() ++ } else { ++ format!("{v}") ++ } ++ } ++ None => "na".to_string(), ++ } ++} ++ ++fn extract_u64(value: &AmlSerdeValue) -> Option { ++ match value { ++ AmlSerdeValue::Integer(i) => Some(*i as u64), ++ _ => None, ++ } ++} ++ ++#[derive(Debug)] ++pub enum FanError { ++ AmlError(AmlError), ++ EvalError(AmlEvalError), ++ NotFound, ++} ++ ++impl From for FanError { ++ fn from(value: AmlError) -> Self { ++ FanError::AmlError(value) ++ } ++} ++ ++impl From for FanError { ++ fn from(value: AmlEvalError) -> Self { ++ FanError::EvalError(value) ++ } ++} ++ ++/// Discovers all ACPI fan devices under the `\_TZ` namespace. ++/// ++/// Walks the AML namespace looking for objects directly under `_TZ` whose ++/// names start with `FAN` (e.g., `FAN0`, `FAN1`). For each, evaluates ++/// fan status methods and returns a populated [`FanDevice`]. ++pub fn discover_fans(ctx: &AcpiContext) -> Vec { ++ let mut fans = Vec::new(); ++ ++ let fan_names = match ctx.fan_device_names() { ++ Ok(names) => names, ++ Err(e) => { ++ log::debug!("Fan device discovery failed: {e:?}"); ++ return fans; ++ } ++ }; ++ ++ for child_name in fan_names { ++ match FanDevice::from_device_eval(ctx, &child_name) { ++ Ok(fan) => { ++ log::info!( ++ "Fan device discovered: {} = level={:?}, rpm={:?}", ++ fan.name, ++ fan.current_level, ++ fan.current_rpm, ++ ); ++ fans.push(fan); ++ } ++ Err(e) => { ++ log::warn!("Fan device {child_name}: discovery failed: {e:?}"); ++ } ++ } ++ } ++ ++ fans ++} ++ ++/// Cached fan device state, refreshed on demand. ++pub struct FanState { ++ fans: RwLock>, ++} ++ ++impl FanState { ++ pub fn new() -> Self { ++ Self { ++ fans: RwLock::new(Vec::new()), ++ } ++ } ++ ++ pub fn refresh(&self, ctx: &AcpiContext) { ++ let discovered = discover_fans(ctx); ++ if let Ok(mut fans) = self.fans.write() { ++ *fans = discovered; ++ } ++ } ++ ++ pub fn fans(&self) -> Vec { ++ self.fans.read().map(|g| g.clone()).unwrap_or_default() ++ } ++ ++ pub fn fan_by_name(&self, name: &str) -> Option { ++ self.fans ++ .read() ++ .ok()? ++ .iter() ++ .find(|f| f.name == name) ++ .cloned() ++ } ++} +diff --git a/drivers/acpid/src/main.rs b/drivers/acpid/src/main.rs +index 91336ba7..c7b8ff3e 100644 +--- a/drivers/acpid/src/main.rs ++++ b/drivers/acpid/src/main.rs +@@ -18,0 +19 @@ mod thermal; ++mod fan; +diff --git a/drivers/acpid/src/scheme.rs b/drivers/acpid/src/scheme.rs +index b92327be..905b42ff 100644 +--- a/drivers/acpid/src/scheme.rs ++++ b/drivers/acpid/src/scheme.rs +@@ -49,0 +50,2 @@ enum HandleKind<'a> { ++ Fan, ++ FanDevice(String), +@@ -64,0 +67,2 @@ impl HandleKind<'_> { ++ Self::Fan => true, ++ Self::FanDevice(_) => false, +@@ -80,0 +85,2 @@ impl HandleKind<'_> { ++ Self::Fan => 0, ++ Self::FanDevice(ref text) => text.len(), +@@ -243,0 +250,8 @@ impl SchemeSync for AcpiScheme<'_, '_> { ++ ["fan"] => HandleKind::Fan, ++ ["fan", device] => { ++ if let Some(fan) = self.ctx.fan_state.fan_by_name(device) { ++ HandleKind::FanDevice(fan.to_text()) ++ } else { ++ return Err(Error::new(ENOENT)); ++ } ++ } +@@ -333,0 +348 @@ impl SchemeSync for AcpiScheme<'_, '_> { ++ HandleKind::FanDevice(ref text) => text.as_bytes(), +@@ -361,0 +377 @@ impl SchemeSync for AcpiScheme<'_, '_> { ++ (DirentKind::Directory, "fan"), +@@ -437,0 +454,17 @@ impl SchemeSync for AcpiScheme<'_, '_> { ++ HandleKind::Fan => { ++ for (idx, fan) in self ++ .ctx ++ .fan_state ++ .fans() ++ .iter() ++ .enumerate() ++ .skip(opaque_offset as usize) ++ { ++ buf.entry(DirEntry { ++ inode: 0, ++ next_opaque_id: idx as u64 + 1, ++ name: &fan.name, ++ kind: DirentKind::Regular, ++ })?; ++ } ++ } +diff --git a/drivers/thermald/src/main.rs b/drivers/thermald/src/main.rs +index 10c4b531..b8d271b5 100644 +--- a/drivers/thermald/src/main.rs ++++ b/drivers/thermald/src/main.rs +@@ -45,0 +46,31 @@ fn read_coretemp_cpus() -> Vec<(String, f32)> { ++fn read_acpi_fans() -> Vec<(String, Option, Option)> { ++ let mut fans = Vec::new(); ++ if let Ok(entries) = fs::read_dir("/scheme/acpi/fan") { ++ for entry in entries.flatten() { ++ let name = entry.file_name().into_string().unwrap_or_default(); ++ if name.starts_with('.') { ++ continue; ++ } ++ let path = format!("/scheme/acpi/fan/{}/status", name); ++ let mut level = None; ++ let mut rpm = None; ++ if let Ok(data) = fs::read_to_string(&path) { ++ for line in data.lines() { ++ if let Some(val) = line.strip_prefix("current_level=") { ++ if val != "na" && val != "unknown" { ++ level = val.parse::().ok(); ++ } ++ } ++ if let Some(val) = line.strip_prefix("current_rpm=") { ++ if val != "na" && val != "unknown" { ++ rpm = val.parse::().ok(); ++ } ++ } ++ } ++ } ++ fans.push((name, level, rpm)); ++ } ++ } ++ fans ++} ++ +@@ -58,0 +90 @@ fn main() -> Result<()> { ++ let fans = read_acpi_fans(); +@@ -90,0 +123,14 @@ fn main() -> Result<()> { ++ for (name, level, rpm) in &fans { ++ match (level, rpm) { ++ (Some(l), Some(r)) => { ++ log::info!("thermald: fan {} = level {}, {} RPM", name, l, r); ++ } ++ (Some(l), None) => { ++ log::info!("thermald: fan {} = level {}", name, l); ++ } ++ _ => { ++ log::debug!("thermald: fan {} = status unavailable", name); ++ } ++ } ++ } ++ diff --git a/recipes/core/base/recipe.toml b/recipes/core/base/recipe.toml index cfc97cfe1f..46b1b34074 100644 --- a/recipes/core/base/recipe.toml +++ b/recipes/core/base/recipe.toml @@ -95,6 +95,8 @@ patches = [ "P46-storage-audio-msix.patch", # P47: Update thermald to read from P44 thermal zones and coretempd "P47-thermald-backend.patch", + # P48: Add ACPI fan device discovery and status exposure + "P48-acpid-fan-support.patch", ] [package]