josie / alder-tools

//! 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<Chip> {
    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<String> {
    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}

fn read_int(path: &Path) -> Option<i64> {
    read_trimmed(path)?.parse().ok()
}

/// All labeled numeric inputs matching `<prefix>N_input`, scaled by `scale`.
/// Prefers the kernel-provided `<prefix>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::<u64>() {
                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::<usize>().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<u64> {
    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<RaplDomain> {
    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<u64> {
    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<f64> {
    sio_input("in0")
}

/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
pub fn sio_input(input: &str) -> Option<f64> {
    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<f64> {
    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<usize> {
    (0..12).collect()
}

/// All logical cpus that are E-cores.
pub fn e_cpus() -> Vec<usize> {
    (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<f64>,
    pub pkg_temp: Option<f64>,
    /// 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<f64>,
    /// 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<u64>,
    /// 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<u64>,
}

/// 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<u64>, 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<u64> = 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<u64>) -> 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<u64> {
        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);
    }
}