//! Read-only access to Linux hardware monitoring sysfs: hwmon chips //! (voltages, temperatures), per-cpu frequency, and RAPL energy counters. //! This is the only module that touches the filesystem — front-ends (the //! run loop, the TUI) never read sysfs directly. //! //! Ported from adlermon/src/sensors.rs (verified 2026-08-29 on this box) //! with one addition: a `Snapshot` struct for atomic per-tick reads, and //! the Alder Lake topology map (P vs E cores) that adlermon keeps in //! ui.rs. Both belong here so run.rs and ui.rs can share them. #![allow(dead_code)] // consumed by run.rs + ui.rs + report.rs — not yet wired 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) } /// Per-cpu current 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 } /// 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-0" } 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. Returns /// None if unreadable (root-only on this kernel without the adm group via /// adlermon's udev rule). 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) } /// 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) } /// Package temperature from coretemp (the "Package id 0" label). pub fn package_temp() -> Option { let chip = discover_chips().into_iter().find(|c| c.name == "coretemp")?; temperatures(&chip) .into_iter() .find(|(label, _)| label.starts_with("Package")) .map(|(_, v)| v) } /// Alder Lake i5-12600KF topology (verified 2026-08-29, mirrored from /// adlermon/project-memory.md). P-cores have HT (2 logical each); E-cores /// don't. Logical cpu 0-11 = P-cores, 12-15 = E-cores. pub fn is_e_core(cpu: usize) -> bool { cpu >= 12 } /// All logical cpus that are P-cores. pub fn p_cpus() -> Vec { (0..12).collect() } /// All logical cpus that are E-cores. pub fn e_cpus() -> Vec { (12..16).collect() } /// One atomic sensor reading at a point in time. The run loop produces one /// per tick; the report writer serializes them; the TUI renders the latest. /// Fields are Option<> because any single sensor can be unreadable (root- /// gated, missing chip, etc.) — absent sensor ≠ error, same as adlermon. #[derive(Clone, Debug, Default)] pub struct Snapshot { /// Seconds since run start (set by the run loop, not sensors.rs). pub t: f64, pub vcore: Option, pub pkg_temp: Option, /// Package power, watts. Computed by the run loop from RAPL energy /// deltas between ticks (sensors.rs exposes the raw counter; the loop /// owns the delta math so it can handle counter wraps + first-tick). pub pkg_power: Option, /// Max frequency across all logical cpus at this tick, kHz. The "peak /// clock" — the per-ISA offset measurement, the whole point of the /// sweep. Run loop also tracks the session peak across ticks. pub peak_clock_khz: Option, /// Per-logical-cpu frequencies (for the TUI's core bars, eventually). pub core_freqs: Vec<(usize, u64)>, /// Raw RAPL energy counter from this tick (for delta math next tick). /// NOT serialized into the report — the report writer skips this field. energy_uj: Option, } /// Read a fresh Snapshot. `prev_energy_uj` is the previous RAPL reading /// for power delta computation — pass None on the first tick. `dt_secs` /// is the elapsed time since the previous tick (for the watts calculation). pub fn snapshot(prev_energy_uj: Option, dt_secs: f64) -> Snapshot { let vcore = sio_vcore(); let pkg_temp = package_temp(); let core_freqs = cpu_frequencies(); let peak_clock_khz = core_freqs.iter().map(|(_, khz)| *khz).max(); // RAPL: find the package domain (kernel name starts with "package"), // read its energy counter, and compute watts from the delta. Same fix // as adlermon 2b920f41 — the kernel label is "package-0" but we match // on starts_with("package") so the watts path actually runs. let mut pkg_power = None; let mut energy_now: Option = None; if let Some(dom) = rapl_domains() .into_iter() .find(|d| d.name.starts_with("package")) { if let Some(e_now) = rapl_energy_uj(&dom.id) { if let Some(e_prev) = prev_energy_uj { if dt_secs > 0.0 { let delta = e_now.saturating_sub(e_prev); pkg_power = Some(delta as f64 / 1_000_000.0 / dt_secs); } } energy_now = Some(e_now); } } Snapshot { t: 0.0, vcore, pkg_temp, pkg_power, peak_clock_khz, core_freqs, energy_uj: None, } .with_energy(energy_now) } impl Snapshot { /// Attach the raw RAPL energy counter so the run loop can feed it back /// as `prev_energy_uj` on the next tick. Stored on the Snapshot so it /// travels with the sample without polluting the report schema (the /// report writer skips this field). pub fn with_energy(mut self, energy_uj: Option) -> Self { self.energy_uj = energy_uj; self } /// The raw RAPL counter from this tick (for delta math next tick). pub fn energy_uj(&self) -> Option { self.energy_uj } } #[cfg(test)] mod tests { use super::*; #[test] fn e_core_classification() { assert!(!is_e_core(0)); assert!(!is_e_core(11)); assert!(is_e_core(12)); assert!(is_e_core(15)); } #[test] fn p_and_e_cpu_lists() { assert_eq!(p_cpus(), vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); assert_eq!(e_cpus(), vec![12, 13, 14, 15]); } #[test] fn snapshot_first_tick_has_no_power() { // First tick (prev_energy_uj = None) can't compute watts yet. let s = snapshot(None, 0.25); // pkg_power should be None on the first tick regardless of RAPL // access — there's no previous reading to delta against. assert_eq!(s.pkg_power, None); } #[test] fn snapshot_energy_round_trips() { let s = snapshot(None, 0.25); // If RAPL is readable, energy_uj() matches what was stored; if not, // both are None. Either way the accessor round-trips. assert_eq!(s.energy_uj(), s.energy_uj); } }