Skip to main content

linux_fan_utility/
config.rs

1// Copyright (c) 2026 Pegasus Heavy Industries LLC
2// Licensed under the MIT License
3
4//! Configuration file handling.
5//!
6//! Persists fan assignments and curve definitions to TOML.
7//! Default path: `/etc/fanctl/config.toml`
8
9use crate::curve::{self, FanCurve};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16/// Default config file location.
17pub const DEFAULT_CONFIG_PATH: &str = "/etc/fanctl/config.toml";
18
19/// Default daemon socket path.
20pub const DEFAULT_SOCKET_PATH: &str = "/run/fanctl.sock";
21
22/// Default poll interval in milliseconds.
23pub const DEFAULT_POLL_INTERVAL_MS: u64 = 2000;
24
25// ---------------------------------------------------------------------------
26// Config types
27// ---------------------------------------------------------------------------
28
29/// Top-level configuration.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Config {
32    /// Daemon settings.
33    #[serde(default)]
34    pub daemon: DaemonConfig,
35
36    /// Named fan curves.
37    #[serde(default)]
38    pub curves: Vec<FanCurve>,
39
40    /// Per-fan assignments, keyed by fan id (e.g. "hwmon3/pwm1").
41    #[serde(default)]
42    pub fans: HashMap<String, FanAssignment>,
43}
44
45/// Daemon-specific settings.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DaemonConfig {
48    /// Poll interval for the curve engine, in milliseconds.
49    #[serde(default = "default_poll_interval")]
50    pub poll_interval_ms: u64,
51
52    /// Path for the Unix domain socket.
53    #[serde(default = "default_socket_path")]
54    pub socket_path: String,
55
56    /// Whether to restore fans to automatic on daemon exit.
57    #[serde(default = "default_true")]
58    pub restore_on_exit: bool,
59}
60
61impl Default for DaemonConfig {
62    fn default() -> Self {
63        Self {
64            poll_interval_ms: DEFAULT_POLL_INTERVAL_MS,
65            socket_path: DEFAULT_SOCKET_PATH.to_string(),
66            restore_on_exit: true,
67        }
68    }
69}
70
71/// How a fan should be controlled.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(tag = "mode")]
74pub enum FanAssignment {
75    /// Automatic (BIOS) control -- daemon doesn't touch this fan.
76    #[serde(rename = "auto")]
77    Auto,
78
79    /// Fixed manual PWM value.
80    #[serde(rename = "manual")]
81    Manual {
82        /// PWM duty 0-255
83        pwm: u8,
84    },
85
86    /// Controlled by a named curve tracking a specific temp sensor.
87    #[serde(rename = "curve")]
88    Curve {
89        /// Name of the curve (must match a curve in `Config::curves`)
90        curve_name: String,
91        /// Id of the temp sensor to read (e.g. "hwmon3/temp1")
92        temp_sensor_id: String,
93    },
94}
95
96impl Default for Config {
97    fn default() -> Self {
98        Self {
99            daemon: DaemonConfig::default(),
100            curves: vec![
101                curve::default_silent_curve(),
102                curve::default_performance_curve(),
103            ],
104            fans: HashMap::new(),
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Load / Save
111// ---------------------------------------------------------------------------
112
113/// Load config from a TOML file, or return the default if the file doesn't exist.
114pub fn load_config(path: &Path) -> io::Result<Config> {
115    if !path.exists() {
116        log::info!("No config file at {}, using defaults", path.display());
117        return Ok(Config::default());
118    }
119
120    let contents = fs::read_to_string(path)?;
121    let config: Config = toml::from_str(&contents).map_err(|e| {
122        io::Error::new(
123            io::ErrorKind::InvalidData,
124            format!("Failed to parse config: {e}"),
125        )
126    })?;
127
128    log::info!("Loaded config from {}", path.display());
129    Ok(config)
130}
131
132/// Save config to a TOML file, creating parent directories if needed.
133pub fn save_config(path: &Path, config: &Config) -> io::Result<()> {
134    if let Some(parent) = path.parent() {
135        fs::create_dir_all(parent)?;
136    }
137
138    let contents = toml::to_string_pretty(config).map_err(|e| {
139        io::Error::new(
140            io::ErrorKind::InvalidData,
141            format!("Failed to serialize config: {e}"),
142        )
143    })?;
144
145    fs::write(path, contents)?;
146    log::info!("Saved config to {}", path.display());
147    Ok(())
148}
149
150/// Resolve the config file path from CLI arg or default.
151pub fn resolve_config_path(cli_path: Option<&str>) -> PathBuf {
152    cli_path
153        .map(PathBuf::from)
154        .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH))
155}
156
157// ---------------------------------------------------------------------------
158// Helpers
159// ---------------------------------------------------------------------------
160
161fn default_poll_interval() -> u64 {
162    DEFAULT_POLL_INTERVAL_MS
163}
164
165fn default_socket_path() -> String {
166    DEFAULT_SOCKET_PATH.to_string()
167}
168
169fn default_true() -> bool {
170    true
171}