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