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