josie / alder-tools

Config file: board rail map + display limits; Rails section in TUI

16cdf793c3a122e1c728c16a2565a8246a824551
josie <administrator@josie-c.com> · 2026-08-29T12:41 · browse files at this commit

parents: 0f53d38

diff --git a/adlermon.conf b/adlermon.conf
new file mode 100644
index 0000000..173849a
--- /dev/null
+++ b/adlermon.conf
@@ -0,0 +1,16 @@
+# adlermon board config — ASRock Z690M ITX/ax (nct6798)
+#
+# Labels marked ? are best guesses from voltage patterns; verify against the
+# BIOS HW Monitor and fix the label. Unwired inputs read floating garbage:
+# set rail.inN.hide = true to hide one, or give it a label to show it.
+# For other boards: delete/override rows freely. Full key list in
+# src/config.rs (vcore_limit, temp_warn, temp_crit, vcore_bar_max,
+# power_bar_max, rail.inN.label, rail.inN.hide).
+
+# vcore_limit = 1.403   # Intel stock spec for 12600KF
+# temp_warn   = 80.0
+# temp_crit   = 95.0
+# vcore_bar_max = 1.50  # bar scale floors; auto-scale rises above these
+# power_bar_max = 200.0
+
+# rail.in12.label = VCCSA   # example: relabel an input
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..91a573f
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,167 @@
+//! Static board config: rail labels + display limits. Zero deps: simple
+//! `KEY = VALUE` lines, `#` comments, blank lines ignored. Unknown keys are
+//! ignored (forward compatibility). Loaded once at startup.
+
+/// Which hwmon input of the SIO chip maps to what. `(key, label)` pairs;
+/// the key is the sysfs stem, e.g. "in0" → `in0_input`.
+pub struct Rail {
+    pub input: String,
+    pub label: String,
+    /// Inputs not connected on this board report garbage (floating) values;
+    /// hide them unless the user maps them.
+    pub hide: bool,
+}
+
+pub struct Config {
+    pub rails: Vec<Rail>,
+    pub vcore_limit: f64,
+    pub temp_warn: f64,
+    pub temp_crit: f64,
+    pub vcore_bar_max: f64,
+    pub power_bar_max: f64,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        Config {
+            rails: default_rails(),
+            vcore_limit: 1.403,
+            temp_warn: 80.0,
+            temp_crit: 95.0,
+            vcore_bar_max: 1.50,
+            power_bar_max: 200.0,
+        }
+    }
+}
+
+/// Best-guess rail map for ASRock Z690M ITX/ax (nct6798). Voltage evidence
+/// from 2026-08-29 probe; labels are GUESSES except in0/12 (load-tested) —
+/// verify against the BIOS HW monitor and fix in the config file.
+pub fn default_rails() -> Vec<Rail> {
+    vec![
+        rail("in0", "CPU Vcore", false),
+        rail("in1", "VCCSA?", false),
+        rail("in2", "+5V?", false),
+        rail("in3", "+3.3V", false),
+        rail("in4", "VCCIN?", false),
+        rail("in5", "", true),
+        rail("in6", "", true),
+        rail("in7", "+5V?", false),
+        rail("in8", "3VSB", false),
+        rail("in9", "VCCIO?", false),
+        rail("in10", "", true),
+        rail("in11", "", true),
+        rail("in12", "VCCSA?", false),
+        rail("in13", "", true),
+        rail("in14", "DRAM VDD", false),
+    ]
+}
+
+fn rail(input: &str, label: &str, hide: bool) -> Rail {
+    Rail {
+        input: input.to_string(),
+        label: label.to_string(),
+        hide,
+    }
+}
+
+/// Parse config text over a default Config. Lines:
+///   rail.in12.label = VCCSA
+///   rail.in5.hide = true
+///   vcore_limit = 1.35
+fn apply_line(cfg: &mut Config, line: &str) {
+    let line = line.split('#').next().unwrap_or("").trim();
+    if line.is_empty() {
+        return;
+    }
+    let Some((key, val)) = line.split_once('=') else {
+        return;
+    };
+    let key = key.trim();
+    let val = val.trim();
+    match key {
+        "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
+        "temp_warn" => cfg.temp_warn = val.parse().unwrap_or(cfg.temp_warn),
+        "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
+        "vcore_bar_max" => cfg.vcore_bar_max = val.parse().unwrap_or(cfg.vcore_bar_max),
+        "power_bar_max" => cfg.power_bar_max = val.parse().unwrap_or(cfg.power_bar_max),
+        "rail.hide" => return, // needs input suffix; handled below
+        _ => {}
+    }
+    if let Some(rest) = key.strip_prefix("rail.") {
+        match rest.split_once('.') {
+            Some((input, "label")) => set_rail(cfg, input, val, None),
+            Some((input, "hide")) => set_rail(cfg, input, "", Some(val == "true")),
+            _ => {}
+        }
+    }
+}
+
+fn set_rail(cfg: &mut Config, input: &str, label: &str, hide: Option<bool>) {
+    if let Some(r) = cfg.rails.iter_mut().find(|r| r.input == input) {
+        if !label.is_empty() {
+            r.label = label.to_string();
+        }
+        if let Some(h) = hide {
+            r.hide = h;
+        }
+    } else {
+        cfg.rails.push(Rail {
+            input: input.to_string(),
+            label: label.to_string(),
+            hide: hide.unwrap_or(false),
+        });
+    }
+}
+
+pub fn load() -> Config {
+    let mut cfg = Config::default();
+    // Search order: ./adlermon.conf then ~/.config/adlermon/adlermon.conf
+    let candidates = [
+        Some(std::path::PathBuf::from("adlermon.conf")),
+        dirs_home().map(|h| h.join(".config/adlermon/adlermon.conf")),
+    ];
+    for path in candidates.into_iter().flatten() {
+        if let Ok(text) = std::fs::read_to_string(&path) {
+            for line in text.lines() {
+                apply_line(&mut cfg, line);
+            }
+            break;
+        }
+    }
+    cfg
+}
+
+fn dirs_home() -> Option<std::path::PathBuf> {
+    std::env::var_os("HOME").map(std::path::PathBuf::from)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_rail_label() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "rail.in12.label = CPU Vccio  # note");
+        assert_eq!(cfg.rails.iter().find(|r| r.input == "in12").unwrap().label, "CPU Vccio");
+    }
+
+    #[test]
+    fn parses_hide_and_limits() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "rail.in5.hide = true");
+        apply_line(&mut cfg, "vcore_limit = 1.35");
+        assert!(cfg.rails.iter().find(|r| r.input == "in5").unwrap().hide);
+        assert_eq!(cfg.vcore_limit, 1.35);
+    }
+
+    #[test]
+    fn tolerates_junk() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "not a config line");
+        apply_line(&mut cfg, "unknown_key = 42");
+        apply_line(&mut cfg, "vcore_limit = notanumber");
+        assert_eq!(cfg.vcore_limit, 1.403);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 40f06ac..f9eedb3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@
 //! 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 config;
 mod sensors;
 mod ui;
 
diff --git a/src/sensors.rs b/src/sensors.rs
index d243d48..29ad604 100644
--- a/src/sensors.rs
+++ b/src/sensors.rs
@@ -208,9 +208,14 @@ pub fn vid_volts(cpu: usize) -> Option<f64> {
 /// 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> {
-    let chip = discover_chips().into_iter().find(|c| c.name == "nct6798")?;
+    sio_input("in0")
+}
+
+/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
+pub fn sio_input(input: &str) -> Option<f64> {
+    let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?;
     voltages(&chip)
         .into_iter()
-        .find(|(label, _)| label == "in0")
+        .find(|(label, _)| label == input)
         .map(|(_, v)| v)
 }
diff --git a/src/ui.rs b/src/ui.rs
index aab61be..7596606 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -11,26 +11,20 @@ use ratatui::{
     text::{Line, Span},
 };
 
+use crate::config;
 use crate::sensors;
 
 const TICK_MS: u64 = 250;
 const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
 /// vCore shown Yellow when within this margin of the limit, Red over it.
 const VCORE_MARGIN: f64 = 0.05;
-/// Safety limit, volts. User-tunable config value. Intel max Vcore
-/// spec default for the 12600KF.
-const VCORE_LIMIT: f64 = 1.403;
-const VCORE_BAR_MAX: f64 = 1.50; // default; auto-scaled above by peak
-const TEMP_WARN: f64 = 80.0;
-const TEMP_CRIT: f64 = 95.0;
-/// Package power bar scale, watts (default; auto-scaled above by peak).
-const POWER_BAR_MAX: f64 = 200.0;
 /// Auto-scale headroom above the session peak, as a fraction.
 const SCALE_MARGIN: f64 = 0.05;
 /// Temp bar scale, °C.
 const TEMP_BAR_MAX: f64 = 100.0;
 
 pub struct App {
+    cfg: config::Config,
     vcore: Option<f64>,
     vcore_peak: Option<f64>,
     /// (timestamp, volts) per-tick maxima inside the 5-min peak window
@@ -65,7 +59,9 @@ struct CoreRow {
 
 impl App {
     pub fn new() -> App {
+        let cfg = config::load();
         App {
+            cfg,
             vcore: None,
             vcore_peak: None,
             peak_ring: Vec::new(),
@@ -193,20 +189,20 @@ fn core_to_cpus(core_id: u64) -> Vec<usize> {
     }
 }
 
-fn temp_color(t: f64) -> Color {
-    if t >= TEMP_CRIT {
+fn temp_color(t: f64, warn: f64, crit: f64) -> Color {
+    if t >= crit {
         Color::Red
-    } else if t >= TEMP_WARN {
+    } else if t >= warn {
         Color::Yellow
     } else {
         Color::Green
     }
 }
 
-fn vcore_color(v: f64) -> Color {
-    if v >= VCORE_LIMIT {
+fn vcore_color(v: f64, limit: f64) -> Color {
+    if v >= limit {
         Color::Red
-    } else if v >= VCORE_LIMIT - VCORE_MARGIN {
+    } else if v >= limit - VCORE_MARGIN {
         Color::Yellow
     } else {
         Color::Green
@@ -292,7 +288,7 @@ pub fn draw(f: &mut Frame, app: &App) {
 
     // ---- vCore hero ----
     let vc_color = match app.vcore {
-        Some(v) => vcore_color(v),
+        Some(v) => vcore_color(v, app.cfg.vcore_limit),
         None => Color::DarkGray,
     };
     let hero_text = match (app.vcore, app.vcore_peak) {
@@ -306,8 +302,16 @@ pub fn draw(f: &mut Frame, app: &App) {
     )));
     // Bar maxima auto-scale: session all-time peak + margin, floored at the
     // static defaults. OC'ing past a default compresses the bar to fit.
-    let vc_max = dynamic_max(app.vcore_all_time_peak, VCORE_BAR_MAX, SCALE_MARGIN);
-    let watt_max = dynamic_max(app.watts_all_time_peak, POWER_BAR_MAX, SCALE_MARGIN);
+    let vc_max = dynamic_max(
+        app.vcore_all_time_peak,
+        app.cfg.vcore_bar_max,
+        SCALE_MARGIN,
+    );
+    let watt_max = dynamic_max(
+        app.watts_all_time_peak,
+        app.cfg.power_bar_max,
+        SCALE_MARGIN,
+    );
 
     lines.push(meter_line(
         "",
@@ -315,10 +319,38 @@ pub fn draw(f: &mut Frame, app: &App) {
         app.vcore.map(|v| v / vc_max),
         vc_color,
         w,
-        Some(VCORE_LIMIT / vc_max),
+        Some(app.cfg.vcore_limit / vc_max),
     ));
     lines.push(Line::from(""));
 
+    // ---- rails (config-mapped SIO inputs) ----
+    let rail_rows: Vec<(String, Option<f64>)> = app
+        .cfg
+        .rails
+        .iter()
+        .filter(|r| !r.hide && r.input != "in0") // in0 shown as the hero
+        .map(|r| (r.label.clone(), sensors::sio_input(&r.input)))
+        .collect();
+    if rail_rows.iter().any(|(_, v)| v.is_some()) {
+        lines.push(Line::from(Span::styled(
+            "Rails",
+            Style::default().fg(Color::Cyan),
+        )));
+        for (label, v) in &rail_rows {
+            let label = if label.is_empty() {
+                "??".to_string()
+            } else {
+                label.clone()
+            };
+            let text = match v {
+                Some(v) => format!("{:.3} V", v),
+                None => "n/a".to_string(),
+            };
+            lines.push(meter_line(&label, &text, None, Color::Magenta, w, None));
+        }
+        lines.push(Line::from(""));
+    }
+
     // ---- package ----
     lines.push(meter_line(
         "Pkg power",
@@ -332,7 +364,9 @@ pub fn draw(f: &mut Frame, app: &App) {
         "Pkg temp",
         &fmt_opt_temp(app.package_temp),
         app.package_temp.map(|t| t / TEMP_BAR_MAX),
-        app.package_temp.map_or(Color::DarkGray, temp_color),
+        app.package_temp.map_or(Color::DarkGray, |t| {
+            temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
+        }),
         w,
         None,
     ));
@@ -366,7 +400,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 _ => None,
             };
             let color = match core.temp {
-                Some(t) => temp_color(t),
+                Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
                 None => Color::DarkGray,
             };
         lines.push(meter_line(&core.label, &text, ratio, color, w, None));