//! Read-only access to Linux hardware monitoring sysfs: hwmon chips //! (voltages, temperatures, fans), per-cpu frequency, and RAPL energy //! counters. This is the only module that touches the filesystem — //! front-ends (dump, later the TUI) never read sysfs directly. use std::fs; use std::path::{Path, PathBuf}; /// A discovered hwmon chip, e.g. `/sys/class/hwmon/hwmon3` (nct6798). pub struct Chip { pub path: PathBuf, pub name: String, } pub fn discover_chips() -> Vec { let mut chips = Vec::new(); if let Ok(entries) = fs::read_dir("/sys/class/hwmon") { for entry in entries.flatten() { let path = entry.path(); if let Some(name) = read_trimmed(&path.join("name")) { chips.push(Chip { path, name }); } } } chips.sort_by_key(|chip| hwmon_number(&chip.path)); chips } fn hwmon_number(path: &Path) -> u64 { path.file_name() .and_then(|n| n.to_str()) .and_then(|n| n.rsplit_once("hwmon")) .and_then(|(_, digits)| digits.parse().ok()) .unwrap_or(u64::MAX) } fn read_trimmed(path: &Path) -> Option { fs::read_to_string(path).ok().map(|s| s.trim().to_string()) } fn read_int(path: &Path) -> Option { read_trimmed(path)?.parse().ok() } /// All labeled numeric inputs matching `N_input`, scaled by `scale`. /// Prefers the kernel-provided `N_label` when one exists, so e.g. /// coretemp's "Core 0" / "Package id 0" names survive. fn numbered_inputs(dir: &Path, prefix: &str, scale: f64) -> Vec<(String, f64)> { let mut rows: Vec<(u64, String, f64)> = Vec::new(); if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let file = entry.file_name(); let file = match file.to_str() { Some(f) => f, None => continue, }; let stem = match file.strip_suffix("_input") { Some(s) => s, None => continue, }; let digits = match stem.strip_prefix(prefix) { Some(d) => d, None => continue, }; let index = match digits.parse::() { Ok(i) => i, Err(_) => continue, }; let raw = match read_int(&entry.path()) { Some(v) => v, None => continue, }; let label = read_trimmed(&dir.join(format!("{stem}_label"))) .unwrap_or_else(|| stem.to_string()); rows.push((index, label, raw as f64 * scale)); } } rows.sort_by_key(|row| row.0); rows.into_iter() .map(|(_, label, value)| (label, value)) .collect() } /// Voltage inputs in volts (`in*_input` is millivolts). pub fn voltages(chip: &Chip) -> Vec<(String, f64)> { numbered_inputs(&chip.path, "in", 1.0 / 1000.0) } /// Temperature inputs in °C (`temp*_input` is millidegrees Celsius). pub fn temperatures(chip: &Chip) -> Vec<(String, f64)> { numbered_inputs(&chip.path, "temp", 1.0 / 1000.0) } /// Fan inputs in RPM. pub fn fans(chip: &Chip) -> Vec<(String, f64)> { numbered_inputs(&chip.path, "fan", 1.0) } /// Number of logical CPUs present (highest `cpuN` index + 1). Used to size /// the per-cpu VID/MSR scan; offline CPUs still enumerate but their MSR read /// fails, yielding None. pub fn cpu_count() -> usize { let mut max = 0; if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") { for entry in entries.flatten() { if let Some(n) = entry .file_name() .to_str() .and_then(|n| n.strip_prefix("cpu")) .and_then(|d| d.parse::().ok()) { max = max.max(n + 1); } } } max } /// Current per-cpu frequency in kHz from cpufreq sysfs (no root required). pub fn cpu_frequencies() -> Vec<(usize, u64)> { let mut out = Vec::new(); if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") { for entry in entries.flatten() { let name = entry.file_name(); let name = match name.to_str() { Some(n) => n, None => continue, }; let cpu = match name .strip_prefix("cpu") .and_then(|d| d.parse::().ok()) { Some(c) => c, None => continue, }; if let Some(khz) = read_int(&entry.path().join("cpufreq/scaling_cur_freq")) { out.push((cpu, khz as u64)); } } } out.sort(); out } /// Min frequency (kHz) advertised for a logical cpu (its idle floor). pub fn cpu_min_freq(cpu: usize) -> Option { read_int( &Path::new("/sys/devices/system/cpu") .join(format!("cpu{cpu}")) .join("cpufreq/cpuinfo_min_freq"), ) .map(|v| v as u64) } /// Max frequency (kHz) advertised for a logical cpu (its turbo ceiling). pub fn cpu_max_freq(cpu: usize) -> Option { read_int( &Path::new("/sys/devices/system/cpu") .join(format!("cpu{cpu}")) .join("cpufreq/cpuinfo_max_freq"), ) .map(|v| v as u64) } /// A RAPL power domain with an `energy_uj` counter, e.g. package or core. pub struct RaplDomain { pub id: String, // sysfs dir name, e.g. "intel-rapl:0" pub name: String, // kernel label, e.g. "package" } pub fn rapl_domains() -> Vec { let mut out = Vec::new(); if let Ok(entries) = fs::read_dir("/sys/class/powercap") { for entry in entries.flatten() { let id = match entry.file_name().to_str() { Some(i) => i.to_string(), None => continue, }; if !id.starts_with("intel-rapl") { continue; } let path = entry.path(); // energy_uj exists on all domains but is 0400 root-only on many // kernels — report the domain regardless and let reads fail loudly. if !path.join("energy_uj").exists() { continue; } let name = read_trimmed(&path.join("name")).unwrap_or_else(|| id.clone()); out.push(RaplDomain { id, name }); } } out.sort_by(|a, b| a.id.cmp(&b.id)); out } /// Cumulative energy in microjoules; deltas over time give watts. pub fn rapl_energy_uj(domain: &str) -> Option { read_int( &Path::new("/sys/class/powercap") .join(domain) .join("energy_uj"), ) .map(|e| e as u64) } /// Per-core MSR read (root + loaded msr module required). Returns the raw /// 64-bit value of `msr` for `cpu`, or None if /dev/cpu/N/msr is unreadable. pub fn read_msr(cpu: usize, msr: u64) -> Option { use std::fs::File; use std::io::{Read, Seek}; let path = Path::new("/dev/cpu").join(cpu.to_string()).join("msr"); let mut f = File::open(path).ok()?; // seek to the MSR address, then read 8 bytes — the /dev/cpu/N/msr ABI f.seek(std::io::SeekFrom::Start(msr)).ok()?; let mut buf = [0u8; 8]; f.read_exact(&mut buf).ok()?; Some(u64::from_le_bytes(buf)) } /// IA32_PERF_STATUS (0x198) raw value for `cpu`. Root + msr module required. /// Kept raw so the log can carry the un-decoded MSR (a consumer can re-derive /// the voltage field if the decode below is ever wrong). pub fn perf_status_msr(cpu: usize) -> Option { read_msr(cpu, 0x198) } /// Decode a raw IA32_PERF_STATUS value to the requested core voltage (SVID /// setpoint), in volts. /// /// The voltage lives in bits [47:32], in units of 1/8192 V (Intel digital /// VID, Haswell+; i7z helper_functions.c:77 reads 47:32 then divides by /// 1<<13). The older Nehalem 8-bit formula on bits 15:8 is WRONG on Alder /// Lake — those bits are the P-state frequency ratio (kernel tsc_msr.c /// reads them as a ratio; turbostat's MSR_PLATFORM_INFO dump too), which is /// why an earlier probe saw a bogus flat 0.54 V (0.5 + idle_ratio 8×0.005). pub fn vid_from_msr(msr: u64) -> f64 { ((msr >> 32) & 0xffff) as f64 / (1u32 << 13) as f64 } /// Read the raw IA32_PERF_STATUS of every logical cpu `0..n` (None where the /// read fails, e.g. an offline cpu). pub fn vid_msrs(n: usize) -> Vec> { (0..n).map(perf_status_msr).collect() } /// Highest requested VID across all logical CPUs (the core asking the VRM /// for the most voltage dominates the VRM's response), or None if no cpu's /// MSR was readable. pub fn vid_max(msrs: &[Option]) -> Option { msrs.iter() .filter_map(|m| m.map(vid_from_msr)) .fold(None, |acc, v| Some(acc.map_or(v, |a: f64| a.max(v)))) } /// Sampled vCore from the nct6798 SIO (hwmon in0). Find the chip by NAME, /// never by index — hwmon numbering shifts between boots. pub fn sio_vcore() -> Option { sio_input("in0") } /// Any SIO voltage input by sysfs stem ("in0", "in12", …), in volts. /// Reads exactly `{stem}_input` after resolving the chip — not via /// `voltages()`, whose label preference could shadow the stem name if a /// future kernel ships `in0_label` (silently breaking the vCore read). pub fn sio_input(input: &str) -> Option { let chip = discover_chips() .into_iter() .find(|c| c.name.starts_with("nct"))?; read_int(&chip.path.join(format!("{input}_input"))).map(|mv| mv as f64 / 1000.0) } #[cfg(test)] mod tests { use super::*; #[test] fn vid_from_msr_decodes_bits_47_32_as_8192ths() { // 1.200 V setpoint = 1.200 × 8192 = 9830.4 → field 9830 (0x2666). assert!((vid_from_msr(9830u64 << 32) - 9830.0 / 8192.0).abs() < 1e-12); // The old (wrong) Nehalem formula read bits 15:8; make sure the idle // ratio 8 no longer leaks into "volts": field 0 must be 0 V. assert_eq!(vid_from_msr(0x0000_0000_0000_0800), 0.0); // Bits below 32 are not part of the voltage field. assert_eq!( vid_from_msr(0x0000_2660_ffff_ffff), vid_from_msr(0x0000_2660_0000_0000) ); } #[test] fn vid_max_ignores_unreadable_cpus() { let msrs = vec![ Some(9000u64 << 32), None, Some(9830u64 << 32), Some(8000u64 << 32), ]; let m = vid_max(&msrs).unwrap(); assert!((m - 9830.0 / 8192.0).abs() < 1e-9); assert_eq!(vid_max(&[None, None]), None); assert_eq!(vid_max(&[]), None); } }