Skip to main content

rigtop_core/linux/
hwmon.rs

1//! Hardware monitoring chips from `/sys/class/hwmon`.
2//!
3//! Each `hwmonN` directory is one chip (k10temp, nct6686, nvme, spd5118,
4//! amdgpu, ...). Channels are numbered files such as `temp1_input` (millidegree
5//! Celsius), `fan1_input` (RPM) and `power1_input` (microwatts).
6
7use std::fs;
8use std::path::Path;
9
10use super::{SysRoots, read_i64, read_trimmed, read_u64, sorted_entries};
11use crate::snapshot::{Fan, SensorChip, Temperature};
12
13/// Chip names whose temperatures describe the CPU.
14const CPU_CHIPS: &[&str] = &["k10temp", "zenpower", "coretemp", "cpu_thermal"];
15
16/// A hwmon chip together with the power channels that [`SensorChip`] does not carry.
17#[derive(Debug, Clone, Default)]
18pub struct HwmonChip {
19    /// Temperatures and fans in their serializable form.
20    pub chip: SensorChip,
21    /// Power readings in watts (`power<N>_input`, or `power<N>_average`).
22    pub power_w: Vec<(String, f32)>,
23    /// amdgpu of an APU (integrated graphics): only APUs have the `vddnb`
24    /// voltage rail. Discrete GPUs report their board power instead.
25    pub is_apu: bool,
26}
27
28/// Reads all hwmon chips, in `hwmonN` order.
29pub fn read_chips(roots: &SysRoots) -> Vec<HwmonChip> {
30    let class_dir = roots.sys.join("class/hwmon");
31    sorted_entries(&class_dir)
32        .into_iter()
33        .filter_map(|entry| read_chip(&class_dir.join(&entry), &entry))
34        // Some hwmon devices (e.g. HID battery reporters) expose no channel
35        // rigtop understands; they would only add empty rows.
36        .filter(|c| {
37            !c.chip.temperatures.is_empty() || !c.chip.fans.is_empty() || !c.power_w.is_empty()
38        })
39        .collect()
40}
41
42/// Returns true if the chip reports CPU temperatures.
43pub fn is_cpu_chip(chip: &SensorChip) -> bool {
44    CPU_CHIPS.contains(&chip.name.as_str())
45}
46
47/// Reads one `hwmonN` directory. Returns `None` if it has no `name` attribute.
48fn read_chip(dir: &Path, entry: &str) -> Option<HwmonChip> {
49    let name = read_trimmed(&dir.join("name"))?;
50    // The `device` link points at the parent device (PCI function, platform
51    // device, NVMe controller, ...). Its last component is stable across
52    // reboots, unlike the hwmonN number.
53    let device = fs::read_link(dir.join("device"))
54        .ok()
55        .and_then(|target| target.file_name().map(|n| n.to_string_lossy().into_owned()))
56        .unwrap_or_else(|| entry.to_owned());
57    let key = format!("{name}:{device}");
58
59    let files = sorted_entries(dir);
60    let temperatures = channel_numbers(&files, "temp")
61        .into_iter()
62        .filter_map(|n| read_temperature(dir, &key, n))
63        .collect();
64    let fans = channel_numbers(&files, "fan")
65        .into_iter()
66        .filter_map(|n| {
67            let rpm = read_u64(&dir.join(format!("fan{n}_input")))?;
68            let label = read_trimmed(&dir.join(format!("fan{n}_label")))
69                .unwrap_or_else(|| format!("fan{n}"));
70            Some(Fan {
71                key: format!("{key}/fan{n}"),
72                label,
73                rpm: rpm.min(u32::MAX as u64) as u32,
74            })
75        })
76        .collect();
77    let power_w = channel_numbers(&files, "power")
78        .into_iter()
79        .filter_map(|n| {
80            // amdgpu exposes `power1_input` on newer kernels and
81            // `power1_average` on older ones.
82            let micro = read_u64(&dir.join(format!("power{n}_input")))
83                .or_else(|| read_u64(&dir.join(format!("power{n}_average"))))?;
84            let label = read_trimmed(&dir.join(format!("power{n}_label")))
85                .unwrap_or_else(|| format!("power{n}"));
86            Some((label, micro as f32 / 1_000_000.0))
87        })
88        .collect();
89
90    let is_apu = name == "amdgpu"
91        && read_trimmed(&dir.join("in1_label")).is_some_and(|label| label == "vddnb");
92    Some(HwmonChip {
93        chip: SensorChip {
94            key,
95            name,
96            temperatures,
97            fans,
98        },
99        power_w,
100        is_apu,
101    })
102}
103
104/// Reads `temp<n>_*` attributes. Returns `None` if the input is unreadable.
105fn read_temperature(dir: &Path, chip_key: &str, n: u32) -> Option<Temperature> {
106    let milli =
107        |suffix: &str| read_i64(&dir.join(format!("temp{n}_{suffix}"))).map(|v| v as f32 / 1000.0);
108    let celsius = milli("input")?;
109    let label =
110        read_trimmed(&dir.join(format!("temp{n}_label"))).unwrap_or_else(|| format!("temp{n}"));
111    Some(Temperature {
112        key: format!("{chip_key}/temp{n}"),
113        label,
114        celsius,
115        max_celsius: milli("max"),
116        critical_celsius: milli("crit"),
117    })
118}
119
120/// Collects the channel numbers `N` of files named `<prefix>N_input` (or
121/// `<prefix>N_average` for power), sorted and de-duplicated.
122fn channel_numbers(files: &[String], prefix: &str) -> Vec<u32> {
123    let mut numbers: Vec<u32> = files
124        .iter()
125        .filter_map(|f| {
126            let rest = f.strip_prefix(prefix)?;
127            let (num, suffix) = rest.split_once('_')?;
128            if suffix == "input" || (prefix == "power" && suffix == "average") {
129                num.parse().ok()
130            } else {
131                None
132            }
133        })
134        .collect();
135    numbers.sort_unstable();
136    numbers.dedup();
137    numbers
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::linux::testutil::{symlink, write};
144    use std::path::PathBuf;
145
146    fn roots(dir: &Path) -> SysRoots {
147        SysRoots {
148            sys: dir.join("sys"),
149            proc: dir.join("proc"),
150        }
151    }
152
153    #[test]
154    fn reads_temperatures_fans_and_power() {
155        let tmp = tempfile::tempdir().unwrap();
156        let root = tmp.path();
157        // k10temp: labelled CCD temperatures, no fans.
158        write(root, "sys/class/hwmon/hwmon5/name", "k10temp\n");
159        write(root, "sys/class/hwmon/hwmon5/temp1_input", "45250\n");
160        write(root, "sys/class/hwmon/hwmon5/temp1_label", "Tctl\n");
161        write(root, "sys/class/hwmon/hwmon5/temp3_input", "40000\n");
162        write(root, "sys/class/hwmon/hwmon5/temp3_label", "Tccd1\n");
163        symlink(
164            root,
165            "sys/class/hwmon/hwmon5/device",
166            &PathBuf::from("../../../0000:00:18.3"),
167        );
168        // nct6686: unlabelled fans, one stopped.
169        write(root, "sys/class/hwmon/hwmon10/name", "nct6686\n");
170        write(root, "sys/class/hwmon/hwmon10/fan1_input", "886\n");
171        write(root, "sys/class/hwmon/hwmon10/fan2_input", "0\n");
172        symlink(
173            root,
174            "sys/class/hwmon/hwmon10/device",
175            &PathBuf::from("../../../nct6683.2592"),
176        );
177        // amdgpu: power in microwatts.
178        write(root, "sys/class/hwmon/hwmon2/name", "amdgpu\n");
179        write(root, "sys/class/hwmon/hwmon2/power1_input", "12500000\n");
180        write(root, "sys/class/hwmon/hwmon2/power1_label", "PPT\n");
181        write(root, "sys/class/hwmon/hwmon2/in1_label", "vddnb\n");
182        // A discrete amdgpu has no vddnb rail.
183        write(root, "sys/class/hwmon/hwmon7/name", "amdgpu\n");
184        write(root, "sys/class/hwmon/hwmon7/power1_input", "80000000\n");
185        write(root, "sys/class/hwmon/hwmon7/in0_label", "vddgfx\n");
186
187        // A chip without any supported channel must be skipped.
188        write(root, "sys/class/hwmon/hwmon3/name", "hid_battery\n");
189
190        let chips = read_chips(&roots(root));
191        // Natural order: hwmon2, hwmon5, hwmon10.
192        let keys: Vec<&str> = chips.iter().map(|c| c.chip.key.as_str()).collect();
193        assert_eq!(
194            keys,
195            vec![
196                "amdgpu:hwmon2",
197                "k10temp:0000:00:18.3",
198                "amdgpu:hwmon7",
199                "nct6686:nct6683.2592"
200            ]
201        );
202
203        let amdgpu = &chips[0];
204        assert_eq!(amdgpu.power_w, vec![("PPT".to_owned(), 12.5)]);
205        assert!(amdgpu.is_apu);
206        assert!(!chips[2].is_apu, "discrete GPU");
207
208        let k10 = &chips[1].chip;
209        assert!(is_cpu_chip(k10));
210        assert_eq!(k10.temperatures.len(), 2);
211        assert_eq!(k10.temperatures[0].label, "Tctl");
212        assert_eq!(k10.temperatures[0].key, "k10temp:0000:00:18.3/temp1");
213        assert!((k10.temperatures[0].celsius - 45.25).abs() < 1e-6);
214        assert_eq!(k10.temperatures[1].label, "Tccd1");
215        assert_eq!(k10.temperatures[1].max_celsius, None);
216
217        let nct = &chips[3].chip;
218        assert_eq!(nct.fans.len(), 2);
219        assert_eq!(nct.fans[0].key, "nct6686:nct6683.2592/fan1");
220        assert_eq!(nct.fans[0].label, "fan1");
221        assert_eq!(nct.fans[0].rpm, 886);
222        assert_eq!(nct.fans[1].rpm, 0);
223    }
224
225    #[test]
226    fn missing_class_directory_yields_no_chips() {
227        let tmp = tempfile::tempdir().unwrap();
228        assert!(read_chips(&roots(tmp.path())).is_empty());
229    }
230}