josie / alder-tools

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