josie / alder-tools

Scaffold: sensor dump tool (nct6798/coretemp/cpufreq/RAPL), verified vCore=in0

cb46ebb330797e24c26b81beddad3032a3a6f3d5
josie <administrator@josie-c.com> · 2026-08-29T11:54 · browse files at this commit

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..63d4885
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+/references
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..1a40c5a
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adlermon"
+version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..a2c0221
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "adlermon"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6d8a478
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# adlermon
+
+btop-style TUI monitor for Alder Lake (i5-12600KF), built around real vCore
+voltage for overclock-safety work.
+
+Current stage: sensor dump tool (`cargo run`). The TUI lands after the
+vCore source spike. See to-do.md.
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..dd2b512
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,48 @@
+//! adlermon — Alder Lake monitor.
+//!
+//! Currently a dump tool: prints a one-shot sensor snapshot to stdout. The
+//! TUI consumes the same `sensors` layer once the vCore source spike is done.
+
+mod sensors;
+
+use std::thread::sleep;
+use std::time::{Duration, Instant};
+
+use sensors::{cpu_frequencies, discover_chips, rapl_domains, rapl_energy_uj};
+
+fn main() {
+    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),
+        }
+    }
+}
diff --git a/src/sensors.rs b/src/sensors.rs
new file mode 100644
index 0000000..b3e1568
--- /dev/null
+++ b/src/sensors.rs
@@ -0,0 +1,164 @@
+//! 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
+}
+
+/// 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)
+}