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

/// 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): 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<f64> {
    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<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)
}