//! 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
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-setup.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.";
#[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,
"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,
'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
}