//! 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, pub vcore_limit: f64, pub temp_warn: f64, pub temp_crit: f64, pub vcore_bar_max: f64, pub power_bar_max: f64, /// Peak-clock plot scale, kHz (P-core turbo ceiling, 4.5 GHz). pub clock_bar_max: f64, /// Core-column arrangement: auto (2 cols if wide), single, or dual. pub layout: Layout, /// Show nvtop-style line graphs in the package zone. pub graphs: bool, /// Tick / sample period, ms. pub poll_ms: u64, /// Graph window (seconds of history shown). History kept is capped by /// the ring size (see plot::Ring::new). pub graph_secs: u64, /// Graph vertical scale: auto (window peak + margin) or fixed (the /// bar_max limits below — no rescaling mid-benchmark). pub graph_scale_fixed: bool, /// Path the config was loaded from (save-back target; None = never /// loaded, defaults in effect). pub loaded_path: Option, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Layout { Auto, Single, Dual, } 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, clock_bar_max: 4_500_000.0, layout: Layout::Auto, graphs: true, poll_ms: 250, graph_secs: 300, graph_scale_fixed: false, loaded_path: None, } } } /// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore /// (shown as the hero meter, not a rail row). v2 view: only CPU-relevant /// rails visible by default (in4 VCCIO?, in9 1.05V PROC); the others stay /// mapped but hidden — re-enable via `rail.inN.hide = false` in the conf. /// Differential stress-ng probing 2026-08-29 + BIOS cross-check; "?" = /// evidence-based guess. pub fn default_rails() -> Vec { vec![ rail("in0", "CPU Vcore", false), rail("in1", "Fixed 1.01", true), rail("in2", "Fixed 3.42", true), rail("in3", "+3.3V", true), rail("in4", "VCCIO?", false), rail("in5", "", true), rail("in6", "", true), rail("in7", "Fixed 3.42", true), rail("in8", "3.3V aux", true), rail("in9", "1.05V PROC", false), rail("in10", "", true), rail("in11", "", true), rail("in12", "Fixed 1.14", true), rail("in13", "", true), rail("in14", "Fixed 1.5V", true), ] } 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), "clock_bar_max" => cfg.clock_bar_max = val.parse().unwrap_or(cfg.clock_bar_max), "graphs" => cfg.graphs = val == "true", "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50), "graph_secs" => cfg.graph_secs = val.parse().unwrap_or(cfg.graph_secs).max(5), "graph_scale_fixed" => cfg.graph_scale_fixed = val == "true", "layout" => { cfg.layout = match val { "single" => Layout::Single, "dual" => Layout::Dual, _ => Layout::Auto, } } "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) { 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); } cfg.loaded_path = Some(path); break; } } cfg } /// Serialize the settings editable in the settings pane. Written back to /// the loaded conf path (F10); rail map and limits stay as-is on disk. pub fn settings_text(cfg: &Config) -> String { format!( "poll_ms = {}\ngraph_secs = {}\ngraph_scale_fixed = {}\ngraphs = {}\nlayout = {}\nvcore_limit = {:.3}\ntemp_warn = {:.1}\ntemp_crit = {:.1}\n", cfg.poll_ms, cfg.graph_secs, cfg.graph_scale_fixed, cfg.graphs, match cfg.layout { Layout::Auto => "auto", Layout::Single => "single", Layout::Dual => "dual", }, cfg.vcore_limit, cfg.temp_warn, cfg.temp_crit, ) } /// Replace the values of the settings-pane keys in conf text (append the /// block if a key is missing). Returns the new file content. pub fn update_conf_text(text: &str, cfg: &Config) -> String { let wanted = settings_text(cfg); let mut out_lines: Vec = Vec::new(); let mut seen: Vec = Vec::new(); for line in text.lines() { let key = line.split('#').next().unwrap_or("").trim(); let key = key.split_once('=').map(|(k, _)| k.trim().to_string()); if let Some(k) = key { if wanted .lines() .any(|w| w.split_once('=').map(|(wk, _)| wk.trim() == k).unwrap_or(false)) && !seen.contains(&k) { // First occurrence of a managed key: emit the fresh block // and mark every block key seen (the block covers them all). for w in wanted.lines() { out_lines.push(w.to_string()); if let Some((wk, _)) = w.split_once('=') { seen.push(wk.trim().to_string()); } } continue; } if seen.contains(&k) { continue; // subsequent dup of a managed key } } out_lines.push(line.to_string()); } let mut out = out_lines.join("\n"); for w in wanted.lines() { let wk = w.split_once('=').unwrap().0.trim(); if !seen.iter().any(|s| s == wk) { out.push('\n'); out.push_str(w); } } out.push('\n'); out } fn dirs_home() -> Option { 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); } #[test] fn parses_layout_and_graphs() { let mut cfg = Config::default(); apply_line(&mut cfg, "layout = dual"); apply_line(&mut cfg, "graphs = false"); assert_eq!(cfg.layout, Layout::Dual); assert!(!cfg.graphs); apply_line(&mut cfg, "layout = nonsense"); assert_eq!(cfg.layout, Layout::Auto); } #[test] fn non_cpu_rails_hidden_by_default() { let cfg = Config::default(); let hidden: Vec<&str> = cfg.rails.iter().filter(|r| r.hide).map(|r| r.input.as_str()).collect(); assert_eq!(hidden, ["in1", "in2", "in3", "in5", "in6", "in7", "in8", "in10", "in11", "in12", "in13", "in14"]); let shown: Vec<&str> = cfg.rails.iter().filter(|r| !r.hide && r.input != "in0").map(|r| r.input.as_str()).collect(); assert_eq!(shown, ["in4", "in9"]); } #[test] fn parses_poll_and_graph_window() { let mut cfg = Config::default(); apply_line(&mut cfg, "poll_ms = 500"); apply_line(&mut cfg, "graph_secs = 300"); assert_eq!(cfg.poll_ms, 500); assert_eq!(cfg.graph_secs, 300); apply_line(&mut cfg, "poll_ms = 1"); // floor 50 assert_eq!(cfg.poll_ms, 50); } #[test] fn updates_conf_in_place() { let text = "# comment\npoll_ms = 250\ngraph_secs = 120\nrail.in0.label = CPU Vcore\ngraphs = true\n"; let mut cfg = Config::default(); cfg.poll_ms = 500; cfg.graph_secs = 300; cfg.graphs = false; let out = update_conf_text(text, &cfg); assert!(out.contains("poll_ms = 500")); assert!(out.contains("graph_secs = 300")); assert!(out.contains("graphs = false")); assert!(out.contains("rail.in0.label = CPU Vcore")); // untouched assert!(out.starts_with("# comment")); // untouched // Each managed key appears exactly once assert_eq!(out.matches("poll_ms").count(), 1); assert_eq!(out.matches("graphs").count(), 1); } #[test] fn parses_scale_fixed() { let mut cfg = Config::default(); apply_line(&mut cfg, "graph_scale_fixed = true"); assert!(cfg.graph_scale_fixed); } #[test] fn settings_text_includes_scale() { let cfg = Config::default(); assert!(settings_text(&cfg).contains("graph_scale_fixed = false")); } }