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/<name>/status. Update thermald to read and log fan
status alongside temperature sensors.
This commit is contained in:
2026-05-20 17:47:39 +03:00
parent ad2e85079d
commit b360748b82
2 changed files with 326 additions and 0 deletions
@@ -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<Vec<String>, 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<u64>,
+ /// Current speed in RPM from _FST (0xFFFFFFFF = unknown).
+ pub current_rpm: Option<u64>,
+}
+
+impl FanDevice {
+ fn from_device_eval(
+ ctx: &AcpiContext,
+ device_name: &str,
+ ) -> Result<Self, FanError> {
+ 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<u64>) -> String {
+ match value {
+ Some(v) => {
+ if v == 0xFFFFFFFF {
+ "unknown".to_string()
+ } else {
+ format!("{v}")
+ }
+ }
+ None => "na".to_string(),
+ }
+}
+
+fn extract_u64(value: &AmlSerdeValue) -> Option<u64> {
+ match value {
+ AmlSerdeValue::Integer(i) => Some(*i as u64),
+ _ => None,
+ }
+}
+
+#[derive(Debug)]
+pub enum FanError {
+ AmlError(AmlError),
+ EvalError(AmlEvalError),
+ NotFound,
+}
+
+impl From<AmlError> for FanError {
+ fn from(value: AmlError) -> Self {
+ FanError::AmlError(value)
+ }
+}
+
+impl From<AmlEvalError> 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<FanDevice> {
+ 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<Vec<FanDevice>>,
+}
+
+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<FanDevice> {
+ self.fans.read().map(|g| g.clone()).unwrap_or_default()
+ }
+
+ pub fn fan_by_name(&self, name: &str) -> Option<FanDevice> {
+ 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<u64>, Option<u64>)> {
+ 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::<u64>().ok();
+ }
+ }
+ if let Some(val) = line.strip_prefix("current_rpm=") {
+ if val != "na" && val != "unknown" {
+ rpm = val.parse::<u64>().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);
+ }
+ }
+ }
+
+2
View File
@@ -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]