//! 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 } /// 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) }