//! 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) } /// 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): bits 15:8 hold the CPU VID (SVID), bit 32 the /// turbo-shift indicator. VID → volts: 0.5 V + VID × 0.005 V (Intel SVID spec). pub fn vid_volts(cpu: usize) -> Option { let msr = read_msr(cpu, 0x198)?; let vid = ((msr >> 8) & 0xff) as f64; Some(0.5 + vid * 0.005) } /// 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", …). pub fn sio_input(input: &str) -> Option { let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?; voltages(&chip) .into_iter() .find(|(label, _)| label == input) .map(|(_, v)| v) }