Skip to main content

linux_fan_utility/
hwmon.rs

1// Copyright (c) 2026 Pegasus Heavy Industries LLC
2// Licensed under the MIT License
3
4//! hwmon sysfs discovery and control.
5//!
6//! Scans `/sys/class/hwmon/` for fan and temperature sensor entries,
7//! and provides read/write access to PWM and sensor values.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15const HWMON_ROOT: &str = "/sys/class/hwmon";
16
17// ---------------------------------------------------------------------------
18// Data types
19// ---------------------------------------------------------------------------
20
21/// A discovered fan (PWM output + optional tachometer input).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Fan {
24    /// Unique identifier, e.g. "hwmon3/pwm1"
25    pub id: String,
26    /// Human-readable label if available
27    pub label: Option<String>,
28    /// Absolute path to the `pwmN` file
29    pub pwm_path: PathBuf,
30    /// Absolute path to the `pwmN_enable` file
31    pub pwm_enable_path: PathBuf,
32    /// Absolute path to the `fanN_input` file (RPM), if present
33    pub rpm_path: Option<PathBuf>,
34    /// Name of the parent hwmon device
35    pub hwmon_name: String,
36}
37
38/// A discovered temperature sensor.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct TempSensor {
41    /// Unique identifier, e.g. "hwmon3/temp1"
42    pub id: String,
43    /// Human-readable label if available
44    pub label: Option<String>,
45    /// Absolute path to the `tempN_input` file (millidegrees C)
46    pub input_path: PathBuf,
47    /// Name of the parent hwmon device
48    pub hwmon_name: String,
49}
50
51/// Live readings for a fan.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct FanStatus {
54    pub id: String,
55    pub label: Option<String>,
56    pub hwmon_name: String,
57    /// Current PWM value 0-255
58    pub pwm: Option<u8>,
59    /// Current PWM enable mode: 0=off, 1=manual, 2=auto
60    pub pwm_enable: Option<u8>,
61    /// Current fan speed in RPM
62    pub rpm: Option<u32>,
63}
64
65/// Live reading for a temperature sensor.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct TempStatus {
68    pub id: String,
69    pub label: Option<String>,
70    pub hwmon_name: String,
71    /// Temperature in degrees Celsius
72    pub temp_c: Option<f64>,
73}
74
75// ---------------------------------------------------------------------------
76// Discovery
77// ---------------------------------------------------------------------------
78
79/// Scan `/sys/class/hwmon` and return all discovered fans.
80pub fn discover_fans() -> io::Result<Vec<Fan>> {
81    let mut fans = Vec::new();
82
83    for entry in fs::read_dir(HWMON_ROOT)? {
84        let entry = entry?;
85        let hwmon_dir = entry.path();
86        let hwmon_name = read_trimmed(&hwmon_dir.join("name")).unwrap_or_default();
87        let hwmon_basename = entry.file_name().to_string_lossy().to_string();
88
89        // Look for pwmN files (N = 1, 2, 3, ...)
90        for n in 1..=16 {
91            let pwm_path = hwmon_dir.join(format!("pwm{n}"));
92            let pwm_enable_path = hwmon_dir.join(format!("pwm{n}_enable"));
93
94            if !pwm_path.exists() {
95                break;
96            }
97
98            let id = format!("{hwmon_basename}/pwm{n}");
99            let label = read_trimmed(&hwmon_dir.join(format!("fan{n}_label")));
100            let rpm_path = {
101                let p = hwmon_dir.join(format!("fan{n}_input"));
102                if p.exists() { Some(p) } else { None }
103            };
104
105            fans.push(Fan {
106                id,
107                label,
108                pwm_path,
109                pwm_enable_path,
110                rpm_path,
111                hwmon_name: hwmon_name.clone(),
112            });
113        }
114    }
115
116    fans.sort_by(|a, b| a.id.cmp(&b.id));
117    Ok(fans)
118}
119
120/// Scan `/sys/class/hwmon` and return all discovered temperature sensors.
121pub fn discover_temp_sensors() -> io::Result<Vec<TempSensor>> {
122    let mut sensors = Vec::new();
123
124    for entry in fs::read_dir(HWMON_ROOT)? {
125        let entry = entry?;
126        let hwmon_dir = entry.path();
127        let hwmon_name = read_trimmed(&hwmon_dir.join("name")).unwrap_or_default();
128        let hwmon_basename = entry.file_name().to_string_lossy().to_string();
129
130        for n in 1..=32 {
131            let input_path = hwmon_dir.join(format!("temp{n}_input"));
132
133            if !input_path.exists() {
134                break;
135            }
136
137            let id = format!("{hwmon_basename}/temp{n}");
138            let label = read_trimmed(&hwmon_dir.join(format!("temp{n}_label")));
139
140            sensors.push(TempSensor {
141                id,
142                label,
143                input_path,
144                hwmon_name: hwmon_name.clone(),
145            });
146        }
147    }
148
149    sensors.sort_by(|a, b| a.id.cmp(&b.id));
150    Ok(sensors)
151}
152
153// ---------------------------------------------------------------------------
154// Reading
155// ---------------------------------------------------------------------------
156
157/// Read current status for a fan.
158pub fn read_fan_status(fan: &Fan) -> FanStatus {
159    let pwm = read_trimmed(&fan.pwm_path)
160        .and_then(|s| s.parse::<u8>().ok());
161    let pwm_enable = read_trimmed(&fan.pwm_enable_path)
162        .and_then(|s| s.parse::<u8>().ok());
163    let rpm = fan.rpm_path.as_ref().and_then(|p| {
164        read_trimmed(p).and_then(|s| s.parse::<u32>().ok())
165    });
166
167    FanStatus {
168        id: fan.id.clone(),
169        label: fan.label.clone(),
170        hwmon_name: fan.hwmon_name.clone(),
171        pwm,
172        pwm_enable,
173        rpm,
174    }
175}
176
177/// Read current status for a temperature sensor.
178pub fn read_temp_status(sensor: &TempSensor) -> TempStatus {
179    let temp_c = read_trimmed(&sensor.input_path)
180        .and_then(|s| s.parse::<i64>().ok())
181        .map(|millic| millic as f64 / 1000.0);
182
183    TempStatus {
184        id: sensor.id.clone(),
185        label: sensor.label.clone(),
186        hwmon_name: sensor.hwmon_name.clone(),
187        temp_c,
188    }
189}
190
191/// Read all fan statuses.
192pub fn read_all_fan_statuses(fans: &[Fan]) -> Vec<FanStatus> {
193    fans.iter().map(read_fan_status).collect()
194}
195
196/// Read all temp statuses.
197pub fn read_all_temp_statuses(sensors: &[TempSensor]) -> Vec<TempStatus> {
198    sensors.iter().map(read_temp_status).collect()
199}
200
201/// Build a map of sensor id -> current temp for quick lookup by the curve engine.
202pub fn read_temp_map(sensors: &[TempSensor]) -> HashMap<String, f64> {
203    let mut map = HashMap::new();
204    for s in sensors {
205        if let Some(t) = read_temp_status(s).temp_c {
206            map.insert(s.id.clone(), t);
207        }
208    }
209    map
210}
211
212// ---------------------------------------------------------------------------
213// Writing
214// ---------------------------------------------------------------------------
215
216/// Set PWM enable mode for a fan.
217///   0 = fan off (full speed on some systems)
218///   1 = manual PWM control
219///   2 = automatic (BIOS/firmware)
220pub fn set_pwm_enable(fan: &Fan, mode: u8) -> io::Result<()> {
221    fs::write(&fan.pwm_enable_path, format!("{mode}"))
222}
223
224/// Set the PWM duty value (0-255) for a fan. The fan must already be in
225/// manual mode (`pwm_enable = 1`).
226pub fn set_pwm(fan: &Fan, value: u8) -> io::Result<()> {
227    fs::write(&fan.pwm_path, format!("{value}"))
228}
229
230/// Put a fan into manual mode and set a specific PWM value.
231pub fn set_manual_pwm(fan: &Fan, value: u8) -> io::Result<()> {
232    set_pwm_enable(fan, 1)?;
233    set_pwm(fan, value)
234}
235
236/// Restore a fan to automatic (BIOS) control.
237pub fn restore_automatic(fan: &Fan) -> io::Result<()> {
238    set_pwm_enable(fan, 2)
239}
240
241/// Restore all fans to automatic control (safety fallback).
242pub fn restore_all_automatic(fans: &[Fan]) {
243    for fan in fans {
244        if let Err(e) = restore_automatic(fan) {
245            log::warn!("Failed to restore automatic control for {}: {e}", fan.id);
246        }
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Helpers
252// ---------------------------------------------------------------------------
253
254fn read_trimmed(path: &Path) -> Option<String> {
255    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
256}