josie / alder-tools

//! aldermon — Alder Lake hardware monitor (i5-12600KF / Z690M ITX/ax).
//!
//! Modes: no args = one-shot sensor dump; `--tui` = live TUI (vCore hero,
//! per-core bars, braille graphs, F2 settings); `--vid` = root-only VID vs
//! in0 cross-check (debug spike, kept as a tool); `--log` = append CSV rows
//! to ./aldermon-vid.log for off-line analysis.

mod config;
mod log;
mod plot;
mod sensors;
mod ui;

use std::io::IsTerminal;
use std::path::Path;
use std::thread::sleep;
use std::time::{Duration, Instant};

use sensors::{cpu_frequencies, discover_chips, rapl_domains, rapl_energy_uj};

const USAGE: &str = "\
usage: aldermon [OPTION]

  (no option)  one-shot sensor dump to stdout
  --tui        live monitoring TUI (q/Esc quit, F2 settings)
  --vid        VID vs SIO in0 cross-check spike (needs MSR access, see below)
  --log        append CSV samples to ./aldermon-vid.log
  --help       show this help
  --version    print version and exit

The TUI always shows the delivered vCore, the requested VID and the delta.
VID needs MSR access: run as root, or install the setcap'd aldermon-msr
helper (doas make install + aldermon-install.sh); otherwise it shows
'VID n/a (no msr access)'.
--log appends one CSV row per poll; with --tui it logs live, with --vid it
logs continuously until interrupted (otherwise --vid runs a 5-sample spike).

Short flags group: -lvt == --log --vid --tui. -V == --version.";

#[derive(Default, Clone, Copy)]
struct Opts {
    tui: bool,
    vid: bool,
    log: bool,
}

pub const LOG_PATH: &str = "aldermon-vid.log";

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let mut opts = Opts::default();
    for a in &args[1..] {
        if let Some(long) = a.strip_prefix("--") {
            match long {
                "tui" => opts.tui = true,
                "vid" => opts.vid = true,
                "log" => opts.log = true,
                "version" => {
                    println!("aldermon {}", env!("CARGO_PKG_VERSION"));
                    return;
                }
                "help" => {
                    println!("{USAGE}");
                    return;
                }
                _ => bad_flag(a),
            }
        } else if let Some(short) = a.strip_prefix('-') {
            for c in short.chars() {
                match c {
                    't' => opts.tui = true,
                    'v' => opts.vid = true,
                    'l' => opts.log = true,
                    'V' => {
                        println!("aldermon {}", env!("CARGO_PKG_VERSION"));
                        return;
                    }
                    'h' => {
                        println!("{USAGE}");
                        return;
                    }
                    _ => bad_flag(a),
                }
            }
        }
    }

    if opts.tui {
        if !std::io::stdout().is_terminal() {
            eprintln!("aldermon: --tui needs a real terminal (stdout is not a tty)");
            std::process::exit(1);
        }
        ui::run_tui(opts.log);
        return;
    }
    if opts.vid {
        vid_spike(opts.log);
        return;
    }
    if opts.log {
        eprintln!("aldermon: --log needs --tui or --vid (nothing to sample)\n{USAGE}");
        std::process::exit(2);
    }
    dump();
}

fn bad_flag(a: &str) -> ! {
    eprintln!("aldermon: unknown option '{a}'\n{USAGE}");
    std::process::exit(2);
}

fn dump() {
    println!("== hwmon ==");
    for chip in discover_chips() {
        println!("[{}] {}", chip.name, chip.path.display());
        for (label, volts) in sensors::voltages(&chip) {
            println!("  {:>18}: {:8.3} V", label, volts);
        }
        for (label, temp) in sensors::temperatures(&chip) {
            println!("  {:>18}: {:7.1} °C", label, temp);
        }
        for (label, rpm) in sensors::fans(&chip) {
            println!("  {:>18}: {:7.0} RPM", label, rpm);
        }
    }

    println!("\n== cpu frequency (MHz) ==");
    for (cpu, khz) in cpu_frequencies() {
        println!("  cpu{:>2}: {:8.1} MHz", cpu, khz as f64 / 1000.0);
    }

    println!("\n== RAPL power (sampled over 500 ms) ==");
    let domains = rapl_domains();
    let first: Vec<Option<u64>> = domains.iter().map(|d| rapl_energy_uj(&d.id)).collect();
    let start = Instant::now();
    sleep(Duration::from_millis(500));
    let elapsed = start.elapsed().as_secs_f64();
    for (domain, first) in domains.iter().zip(first) {
        match (first, rapl_energy_uj(&domain.id)) {
            (Some(a), Some(b)) if b >= a => {
                let watts = (b - a) as f64 / 1_000_000.0 / elapsed;
                println!("  {:>18} ({}): {:6.2} W", domain.name, domain.id, watts);
            }
            _ => println!("  {:>18} ({}): unreadable", domain.name, domain.id),
        }
    }
}

/// VID cross-check: compare SIO in0 (delivered rail) with the CPU's requested
/// SVID setpoint (IA32_PERF_STATUS) per sample. Needs MSR access (root or
/// the setcap'd aldermon-msr helper).
/// Without `log` this is a bounded 5-sample spike; with `log` it runs until
/// interrupted, appending CSV rows to ./aldermon-vid.log.
fn vid_spike(log: bool) {
    let cpus = sensors::cpu_count();
    let access = sensors::probe_msr();
    let mut logger = if log {
        match log::VidLogger::open(Path::new(LOG_PATH), cpus) {
            Ok(l) => {
                println!("logging to ./{LOG_PATH} ({cpus} cpus) — Ctrl-C to stop");
                Some(l)
            }
            Err(e) => {
                eprintln!("aldermon: cannot open {LOG_PATH}: {e}");
                std::process::exit(1);
            }
        }
    } else {
        None
    };

    if logger.is_none() {
        println!("== VID cross-check (MSR access: root, or setcap'd aldermon-msr) ==");
        println!("(hint: --log appends to ./{LOG_PATH} continuously)");
        if !access.usable() {
            println!("(no MSR access — run under root, or install + setup the helper)");
        }
    }
    // Package-energy counter for the logged watts (None until the second
    // sample; the first delta has no baseline).
    let pkg = rapl_domains()
        .into_iter()
        .find(|d| d.name.starts_with("package"));
    let mut prev_energy: Option<(u64, Instant)> = None;
    let rounds: u64 = if logger.is_some() { u64::MAX } else { 5 };
    for round in 0..rounds {
        let vcore = sensors::sio_vcore();
        let msrs = sensors::vid_msrs(access, cpus);
        let vmax = sensors::vid_max(&msrs);
        let freqs = cpu_frequencies();

        // Watts over this inter-sample interval (same math as the TUI).
        let watts = pkg.as_ref().and_then(|d| {
            let e = sensors::rapl_energy_uj(&d.id)?;
            let w = prev_energy.and_then(|(prev, t0)| {
                let dt = t0.elapsed().as_secs_f64();
                (dt > 0.0 && e >= prev).then(|| (e - prev) as f64 / 1_000_000.0 / dt)
            });
            prev_energy = Some((e, Instant::now()));
            w
        });

        if let Some(l) = logger.as_mut() {
            let sample = log::Sample {
                sio_vcore: vcore,
                vid_max: vmax,
                pkg_watts: watts,
                vid_msr: msrs.clone(),
                freq_khz: freq_by_cpu(&freqs, cpus),
            };
            if let Err(e) = l.log(&sample) {
                eprintln!("aldermon: log write failed: {e}");
            }
            match (vcore, vmax) {
                (Some(got), Some(ask)) => {
                    println!("in0 {got:.3} V  VID {ask:.3} V  Δ{:+.3}", got - ask)
                }
                (Some(got), None) => println!("in0 {got:.3} V  VID unreadable"),
                (None, Some(ask)) => println!("in0 unreadable  VID {ask:.3} V"),
                (None, None) => println!("in0 unreadable  VID unreadable"),
            }
        } else {
            println!("-- sample {} --", round);
            match vcore {
                Some(v) => println!("  SIO in0 (vCore): {v:.3} V"),
                None => println!("  SIO in0 (vCore): UNAVAILABLE"),
            }
            for (cpu, msr) in msrs.iter().enumerate() {
                match msr {
                    Some(m) => println!(
                        "  cpu{cpu:>2} VID: {:.3} V  (0x{m:016x})",
                        sensors::vid_from_msr(*m)
                    ),
                    None => println!("  cpu{cpu:>2} VID: unreadable"),
                }
            }
        }
        sleep(Duration::from_millis(400));
    }
}

/// Per-logical-cpu frequency vector (kHz), empty where a cpu has no reading.
fn freq_by_cpu(freqs: &[(usize, u64)], cpus: usize) -> Vec<Option<u64>> {
    let mut out = vec![None; cpus];
    for (cpu, khz) in freqs {
        if let Some(slot) = out.get_mut(*cpu) {
            *slot = Some(*khz);
        }
    }
    out
}