josie / alder-tools

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

/// 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::<usize>().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::<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
}

/// Min frequency (kHz) advertised for a logical cpu (its idle floor).
pub fn cpu_min_freq(cpu: usize) -> Option<u64> {
    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<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"
}

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.
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)
}

/// 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<u64> {
    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<u64> {
    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
}

/// How we can reach /dev/cpu/N/msr: opening it takes BOTH file read access
/// (udev puts the nodes 0440 group adm) and CAP_SYS_RAWIO (msr_open), which
/// only setcap or root can grant — so the unprivileged route is the setcap'd
/// `aldermon-msr` helper. Probed once at startup; re-probing per poll would
/// fork the helper on every tick.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MsrAccess {
    Direct,
    Helper,
    None,
}

impl MsrAccess {
    pub fn usable(self) -> bool {
        self != MsrAccess::None
    }
}

/// Locate the helper: sibling of the running exe first (installed layout and
/// `cargo run` target/debug both put them side by side), else PATH.
fn helper_path() -> std::path::PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|exe| {
            let sib = exe.parent()?.join("aldermon-msr");
            sib.exists().then_some(sib)
        })
        .unwrap_or_else(|| std::path::PathBuf::from("aldermon-msr"))
}

/// Parse helper stdout: "CPU 0x<hex>" lines, "CPU -" for misses. Short or
/// malformed output fills None slots so the result always covers all cpus.
fn parse_helper_stdout(s: &str, n: usize) -> Vec<Option<u64>> {
    let mut out = vec![None; n];
    for (slot, line) in out.iter_mut().zip(s.lines()) {
        if let Some(val) = line.split_once(' ').map(|(_, v)| v) {
            if val != "-" {
                *slot = u64::from_str_radix(val.trim_start_matches("0x"), 16).ok();
            }
        }
    }
    out
}

/// One helper exec for all cpus (a 50 ms poll = one fork+exec, not 16).
fn helper_perf_status(n: usize) -> Option<Vec<Option<u64>>> {
    let out = std::process::Command::new(helper_path())
        .args((0..n).map(|c| c.to_string()).collect::<Vec<_>>())
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    Some(parse_helper_stdout(
        &String::from_utf8_lossy(&out.stdout),
        n,
    ))
}

/// Probe once which MSR route works (cpu 0 is always online).
pub fn probe_msr() -> MsrAccess {
    if perf_status_msr(0).is_some() {
        return MsrAccess::Direct;
    }
    match helper_perf_status(1).map(|v| v[0]) {
        Some(Some(_)) => MsrAccess::Helper,
        _ => MsrAccess::None,
    }
}

/// Read the raw IA32_PERF_STATUS of every logical cpu `0..n` (None where the
/// read fails, e.g. an offline cpu), via the probed access route.
pub fn vid_msrs(access: MsrAccess, n: usize) -> Vec<Option<u64>> {
    match access {
        MsrAccess::Direct => (0..n).map(perf_status_msr).collect(),
        MsrAccess::Helper => helper_perf_status(n).unwrap_or_else(|| vec![None; n]),
        MsrAccess::None => vec![None; n],
    }
}

/// 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<u64>]) -> Option<f64> {
    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<f64> {
    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<f64> {
    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-12);
        assert_eq!(vid_max(&[None, None]), None);
        assert_eq!(vid_max(&[]), None);
    }

    #[test]
    fn parse_helper_stdout_maps_values_misses_and_gaps() {
        let got = parse_helper_stdout("0 0x266600000000\n1 -\n2 0x1f4000000000", 3);
        assert_eq!(
            got,
            vec![Some(0x2666_0000_0000), None, Some(0x1f40_0000_0000)]
        );
        // fewer lines than cpus → trailing None; junk line → that slot None
        let got = parse_helper_stdout("0 0xff\n", 3);
        assert_eq!(got, vec![Some(0xff), None, None]);
        let got = parse_helper_stdout("0 zz\n", 1);
        assert_eq!(got, vec![None]);
        assert_eq!(parse_helper_stdout("", 2), vec![None, None]);
    }
}