From 98bbf28f3d1200a27457c80014ea0fe4076427ab Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 21:25:26 +0300 Subject: [PATCH 1/7] config: nuke old config and implement a new system --- Cargo.toml | 2 +- src/config.rs | 138 ++++++++++++++++++++++ src/config/load.rs | 128 -------------------- src/config/mod.rs | 5 - src/config/types.rs | 282 -------------------------------------------- src/cpu.rs | 4 +- src/main.rs | 73 +++--------- 7 files changed, 158 insertions(+), 474 deletions(-) create mode 100644 src/config.rs delete mode 100644 src/config/load.rs delete mode 100644 src/config/mod.rs delete mode 100644 src/config/types.rs diff --git a/Cargo.toml b/Cargo.toml index 3276b4a..287929e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ rust-version = "1.85" serde = { version = "1.0", features = ["derive"] } toml = "0.8" dirs = "6.0" -clap = { version = "4.0", features = ["derive"] } +clap = { version = "4.0", features = ["derive", "env"] } num_cpus = "1.16" ctrlc = "3.4" log = "0.4" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0e07031 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,138 @@ +use std::{fs, path::Path}; + +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; + +fn is_default(value: &T) -> bool { + *value == T::default() +} + +#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub struct CpuDelta { + /// The CPUs to apply the changes to. When unspecified, will be applied to all CPUs. + #[arg(short = 'c', long = "for")] + #[serde(rename = "for", skip_serializing_if = "is_default")] + pub for_: Option>, + + /// Set the CPU governor. + #[arg(short = 'g', long)] + #[serde(skip_serializing_if = "is_default")] + pub governor: Option, // TODO: Validate with clap for available governors. + + /// Set CPU Energy Performance Preference (EPP). Short form: --epp. + #[arg(short = 'p', long, alias = "epp")] + #[serde(skip_serializing_if = "is_default")] + pub energy_performance_preference: Option, // TODO: Validate with clap for available governors. + + /// Set CPU Energy Performance Bias (EPB). Short form: --epb. + #[arg(short = 'b', long, alias = "epb")] + #[serde(skip_serializing_if = "is_default")] + pub energy_performance_bias: Option, // TODO: Validate with clap for available governors. + + /// Set minimum CPU frequency in MHz. Short form: --freq-min. + #[arg(short = 'f', long, alias = "freq-min", value_parser = clap::value_parser!(u64).range(1..=10_000))] + #[serde(skip_serializing_if = "is_default")] + pub frequency_mhz_minimum: Option, + + /// Set maximum CPU frequency in MHz. Short form: --freq-max. + #[arg(short = 'F', long, alias = "freq-max", value_parser = clap::value_parser!(u64).range(1..=10_000))] + #[serde(skip_serializing_if = "is_default")] + pub frequency_mhz_maximum: Option, + + /// Set turbo boost behaviour. Has to be for all CPUs. + #[arg(short = 't', long, conflicts_with = "for_")] + #[serde(skip_serializing_if = "is_default")] + pub turbo: Option, +} + +#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub struct PowerDelta { + /// The power supplies to apply the changes to. When unspecified, will be applied to all power supplies. + #[arg(short = 'p', long = "for")] + #[serde(rename = "for", skip_serializing_if = "is_default")] + pub for_: Option>, + + /// Set the percentage that the power supply has to drop under for charging to start. Short form: --charge-start. + #[arg(short = 'c', long, alias = "charge-start", value_parser = clap::value_parser!(u8).range(0..=100))] + #[serde(skip_serializing_if = "is_default")] + pub charge_threshold_start: Option, + + /// Set the percentage where charging will stop. Short form: --charge-end. + #[arg(short = 'C', long, alias = "charge-end", value_parser = clap::value_parser!(u8).range(0..=100))] + #[serde(skip_serializing_if = "is_default")] + pub charge_threshold_end: Option, + + /// Set ACPI platform profile. Has to be for all power supplies. + #[arg(short = 'f', long, alias = "profile", conflicts_with = "for_")] + #[serde(skip_serializing_if = "is_default")] + pub platform_profile: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Hash)] +#[serde(untagged, rename_all = "kebab-case")] +pub enum Condition { + ChargeLessThan(u8), + ChargeMoreThan(u8), + + TemperatureLessThan(u8), + TemperatureMoreThan(u8), + + UtilizationLessThan(u8), + UtilizationMoreThan(u8), + + Charging, + OnBattery, + + False, + #[default] + True, + + All(Vec), + Any(Vec), + + Not(Box), +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct DaemonConfigLayer { + priority: u8, + + #[serde(default, skip_serializing_if = "is_default")] + if_: Condition, + + #[serde(default, skip_serializing_if = "is_default")] + cpu: CpuDelta, + #[serde(default, skip_serializing_if = "is_default")] + power: PowerDelta, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(transparent, default, rename_all = "kebab-case")] +pub struct DaemonConfig(pub Vec); + +impl DaemonConfig { + pub fn load_from(path: &Path) -> anyhow::Result { + let contents = fs::read_to_string(path).with_context(|| { + format!("failed to read config from '{path}'", path = path.display()) + })?; + + let config: Self = toml::from_str(&contents).context("failed to parse config file")?; + + { + let mut priorities = Vec::with_capacity(config.0.len()); + + for layer in &config.0 { + if priorities.contains(&layer.priority) { + bail!("each config layer must have a different priority") + } + + priorities.push(layer.priority); + } + } + + Ok(config) + } +} diff --git a/src/config/load.rs b/src/config/load.rs deleted file mode 100644 index 15f4248..0000000 --- a/src/config/load.rs +++ /dev/null @@ -1,128 +0,0 @@ -// Configuration loading functionality -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::Context as _; - -use crate::config::types::{AppConfig, AppConfigToml, DaemonConfig, ProfileConfig}; - -/// The primary function to load application configuration from a specific path or from default locations. -/// -/// # Arguments -/// -/// * `specific_path` - If provided, only attempts to load from this path and errors if not found -/// -/// # Returns -/// -/// * `Ok(AppConfig)` - Successfully loaded configuration -/// * `Err(ConfigError)` - Error loading or parsing configuration -pub fn load_config() -> anyhow::Result { - load_config_from_path(None) -} - -/// Load configuration from a specific path or try default paths -pub fn load_config_from_path(specific_path: Option<&str>) -> anyhow::Result { - // If a specific path is provided, only try that one - if let Some(path_str) = specific_path { - let path = Path::new(path_str); - if path.exists() { - return load_and_parse_config(path); - } - - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Specified config file not found: {}", path.display()), - ))?; - } - - // Check for SUPERFREQ_CONFIG environment variable - if let Ok(env_path) = std::env::var("SUPERFREQ_CONFIG") { - let env_path = Path::new(&env_path); - if env_path.exists() { - println!( - "Loading config from SUPERFREQ_CONFIG: {}", - env_path.display() - ); - return load_and_parse_config(env_path); - } - eprintln!( - "Warning: Config file specified by SUPERFREQ_CONFIG not found: {}", - env_path.display() - ); - } - - // System-wide paths - let config_paths = vec![ - PathBuf::from("/etc/xdg/superfreq/config.toml"), - PathBuf::from("/etc/superfreq.toml"), - ]; - - for path in config_paths { - if path.exists() { - println!("Loading config from: {}", path.display()); - match load_and_parse_config(&path) { - Ok(config) => return Ok(config), - Err(e) => { - eprintln!("Error with config file {}: {}", path.display(), e); - // Continue trying other files - } - } - } - } - - println!("No configuration file found or all failed to parse. Using default configuration."); - // Construct default AppConfig by converting default AppConfigToml - let default_toml_config = AppConfigToml::default(); - Ok(AppConfig { - charger: ProfileConfig::from(default_toml_config.charger), - battery: ProfileConfig::from(default_toml_config.battery), - ignored_power_supplies: default_toml_config.ignored_power_supplies, - daemon: DaemonConfig::default(), - }) -} - -/// Load and parse a configuration file -fn load_and_parse_config(path: &Path) -> anyhow::Result { - let contents = fs::read_to_string(path).with_context(|| { - format!( - "failed to read config file from '{path}'", - path = path.display(), - ) - })?; - - let toml_app_config = - toml::from_str::(&contents).context("failed to parse config toml")?; - - // Handle inheritance of values from global to profile configs - let mut charger_profile = toml_app_config.charger.clone(); - let mut battery_profile = toml_app_config.battery.clone(); - - // Clone global battery_charge_thresholds once if it exists - if let Some(global_thresholds) = toml_app_config.battery_charge_thresholds { - // Apply to charger profile if not already set - if charger_profile.battery_charge_thresholds.is_none() { - charger_profile.battery_charge_thresholds = Some(global_thresholds.clone()); - } - - // Apply to battery profile if not already set - if battery_profile.battery_charge_thresholds.is_none() { - battery_profile.battery_charge_thresholds = Some(global_thresholds); - } - } - - // Convert AppConfigToml to AppConfig - Ok(AppConfig { - charger: ProfileConfig::from(charger_profile), - battery: ProfileConfig::from(battery_profile), - ignored_power_supplies: toml_app_config.ignored_power_supplies, - daemon: DaemonConfig { - poll_interval_sec: toml_app_config.daemon.poll_interval_sec, - adaptive_interval: toml_app_config.daemon.adaptive_interval, - min_poll_interval_sec: toml_app_config.daemon.min_poll_interval_sec, - max_poll_interval_sec: toml_app_config.daemon.max_poll_interval_sec, - throttle_on_battery: toml_app_config.daemon.throttle_on_battery, - log_level: toml_app_config.daemon.log_level, - stats_file_path: toml_app_config.daemon.stats_file_path, - }, - }) -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index c2f3076..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod load; -pub mod types; - -pub use load::*; -pub use types::*; diff --git a/src/config/types.rs b/src/config/types.rs deleted file mode 100644 index c0be6e2..0000000 --- a/src/config/types.rs +++ /dev/null @@ -1,282 +0,0 @@ -use anyhow::bail; -// Configuration types and structures for superfreq -use serde::{Deserialize, Serialize}; -use std::convert::TryFrom; - -/// Defines constant-returning functions used for default values. -/// This hopefully reduces repetition since we have way too many -/// default functions that just return constants. -macro_rules! default_const { - ($($name:ident -> $type:ty = $value:expr;)*) => { - $( - const fn $name() -> $type { - $value - } - )* - }; -} - -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] -pub struct PowerSupplyChargeThresholds { - pub start: u8, - pub stop: u8, -} - -impl TryFrom<(u8, u8)> for PowerSupplyChargeThresholds { - type Error = anyhow::Error; - - fn try_from((start, stop): (u8, u8)) -> anyhow::Result { - if stop == 0 { - bail!("stop threshold must be greater than 0%"); - } - if start >= stop { - bail!("start threshold ({start}) must be less than stop threshold ({stop})"); - } - if stop > 100 { - bail!("stop threshold ({stop}) cannot exceed 100%"); - } - - Ok(PowerSupplyChargeThresholds { start, stop }) - } -} - -// Structs for configuration using serde::Deserialize -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct ProfileConfig { - pub governor: Option, - pub turbo: Option, - pub epp: Option, // Energy Performance Preference (EPP) - pub epb: Option, // Energy Performance Bias (EPB) - usually an integer, but string for flexibility from sysfs - pub min_freq_mhz: Option, - pub max_freq_mhz: Option, - pub platform_profile: Option, - #[serde(default)] - pub turbo_auto_settings: TurboAutoSettings, - #[serde(default)] - pub enable_auto_turbo: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, -} - -impl Default for ProfileConfig { - fn default() -> Self { - Self { - governor: Some("schedutil".to_string()), // common sensible default (?) - turbo: None, - epp: None, // defaults depend on governor and system - epb: None, // defaults depend on governor and system - min_freq_mhz: None, // no override - max_freq_mhz: None, // no override - platform_profile: None, // no override - turbo_auto_settings: TurboAutoSettings::default(), - enable_auto_turbo: default_enable_auto_turbo(), - battery_charge_thresholds: None, - } - } -} - -#[derive(Deserialize, Serialize, Debug, Default, Clone)] -pub struct AppConfig { - #[serde(default)] - pub charger: ProfileConfig, - #[serde(default)] - pub battery: ProfileConfig, - pub ignored_power_supplies: Option>, - #[serde(default)] - pub daemon: DaemonConfig, -} - -// Intermediate structs for TOML parsing -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct ProfileConfigToml { - pub governor: Option, - pub turbo: Option, // "always", "auto", "never" - pub epp: Option, - pub epb: Option, - pub min_freq_mhz: Option, - pub max_freq_mhz: Option, - pub platform_profile: Option, - pub turbo_auto_settings: Option, - #[serde(default = "default_enable_auto_turbo")] - pub enable_auto_turbo: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone, Default)] -pub struct AppConfigToml { - #[serde(default)] - pub charger: ProfileConfigToml, - #[serde(default)] - pub battery: ProfileConfigToml, - #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, - pub ignored_power_supplies: Option>, - #[serde(default)] - pub daemon: DaemonConfigToml, -} - -impl Default for ProfileConfigToml { - fn default() -> Self { - Self { - governor: Some("schedutil".to_string()), - turbo: Some("auto".to_string()), - epp: None, - epb: None, - min_freq_mhz: None, - max_freq_mhz: None, - platform_profile: None, - turbo_auto_settings: None, - enable_auto_turbo: default_enable_auto_turbo(), - battery_charge_thresholds: None, - } - } -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct TurboAutoSettings { - #[serde(default = "default_load_threshold_high")] - pub load_threshold_high: f32, - #[serde(default = "default_load_threshold_low")] - pub load_threshold_low: f32, - #[serde(default = "default_temp_threshold_high")] - pub temp_threshold_high: f32, - /// Initial turbo boost state when no previous state exists. - /// Set to `true` to start with turbo enabled, `false` to start with turbo disabled. - /// This is only used at first launch or after a reset. - #[serde(default = "default_initial_turbo_state")] - pub initial_turbo_state: bool, -} - -// Default thresholds for Auto turbo mode -pub const DEFAULT_LOAD_THRESHOLD_HIGH: f32 = 70.0; // enable turbo if load is above this -pub const DEFAULT_LOAD_THRESHOLD_LOW: f32 = 30.0; // disable turbo if load is below this -pub const DEFAULT_TEMP_THRESHOLD_HIGH: f32 = 75.0; // disable turbo if temperature is above this -pub const DEFAULT_INITIAL_TURBO_STATE: bool = false; // by default, start with turbo disabled - -default_const! { - default_load_threshold_high -> f32 = DEFAULT_LOAD_THRESHOLD_HIGH; - default_load_threshold_low -> f32 = DEFAULT_LOAD_THRESHOLD_LOW; - - default_temp_threshold_high -> f32 = DEFAULT_TEMP_THRESHOLD_HIGH; - - default_initial_turbo_state -> bool = DEFAULT_INITIAL_TURBO_STATE; -} - -impl Default for TurboAutoSettings { - fn default() -> Self { - Self { - load_threshold_high: DEFAULT_LOAD_THRESHOLD_HIGH, - load_threshold_low: DEFAULT_LOAD_THRESHOLD_LOW, - temp_threshold_high: DEFAULT_TEMP_THRESHOLD_HIGH, - initial_turbo_state: DEFAULT_INITIAL_TURBO_STATE, - } - } -} - -impl From for ProfileConfig { - fn from(toml_config: ProfileConfigToml) -> Self { - Self { - governor: toml_config.governor, - turbo: toml_config - .turbo - .and_then(|s| match s.to_lowercase().as_str() { - "always" => Some(true), - "auto" => None, - "never" => Some(false), - _ => panic!("invalid turbo value: {s}, must be one of: always, auto, never"), - }), - epp: toml_config.epp, - epb: toml_config.epb, - min_freq_mhz: toml_config.min_freq_mhz, - max_freq_mhz: toml_config.max_freq_mhz, - platform_profile: toml_config.platform_profile, - turbo_auto_settings: toml_config.turbo_auto_settings.unwrap_or_default(), - enable_auto_turbo: toml_config.enable_auto_turbo, - battery_charge_thresholds: toml_config.battery_charge_thresholds, - } - } -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct DaemonConfig { - #[serde(default = "default_poll_interval_sec")] - pub poll_interval_sec: u64, - #[serde(default = "default_adaptive_interval")] - pub adaptive_interval: bool, - #[serde(default = "default_min_poll_interval_sec")] - pub min_poll_interval_sec: u64, - #[serde(default = "default_max_poll_interval_sec")] - pub max_poll_interval_sec: u64, - #[serde(default = "default_throttle_on_battery")] - pub throttle_on_battery: bool, - #[serde(default = "default_log_level")] - pub log_level: LogLevel, - #[serde(default = "default_stats_file_path")] - pub stats_file_path: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)] -pub enum LogLevel { - Error, - Warning, - Info, - Debug, -} - -impl Default for DaemonConfig { - fn default() -> Self { - Self { - poll_interval_sec: default_poll_interval_sec(), - adaptive_interval: default_adaptive_interval(), - min_poll_interval_sec: default_min_poll_interval_sec(), - max_poll_interval_sec: default_max_poll_interval_sec(), - throttle_on_battery: default_throttle_on_battery(), - log_level: default_log_level(), - stats_file_path: default_stats_file_path(), - } - } -} - -default_const! { - default_poll_interval_sec -> u64 = 5; - default_adaptive_interval -> bool = false; - default_min_poll_interval_sec -> u64 = 1; - default_max_poll_interval_sec -> u64 = 30; - default_throttle_on_battery -> bool = true; - default_log_level -> LogLevel = LogLevel::Info; - default_stats_file_path -> Option = None; - default_enable_auto_turbo -> bool = true; -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct DaemonConfigToml { - #[serde(default = "default_poll_interval_sec")] - pub poll_interval_sec: u64, - #[serde(default = "default_adaptive_interval")] - pub adaptive_interval: bool, - #[serde(default = "default_min_poll_interval_sec")] - pub min_poll_interval_sec: u64, - #[serde(default = "default_max_poll_interval_sec")] - pub max_poll_interval_sec: u64, - #[serde(default = "default_throttle_on_battery")] - pub throttle_on_battery: bool, - #[serde(default = "default_log_level")] - pub log_level: LogLevel, - #[serde(default = "default_stats_file_path")] - pub stats_file_path: Option, -} - -impl Default for DaemonConfigToml { - fn default() -> Self { - Self { - poll_interval_sec: default_poll_interval_sec(), - adaptive_interval: default_adaptive_interval(), - min_poll_interval_sec: default_min_poll_interval_sec(), - max_poll_interval_sec: default_max_poll_interval_sec(), - throttle_on_battery: default_throttle_on_battery(), - log_level: default_log_level(), - stats_file_path: default_stats_file_path(), - } - } -} diff --git a/src/cpu.rs b/src/cpu.rs index 2d7a32d..d0985e9 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -14,7 +14,7 @@ fn read_u64(path: impl AsRef) -> anyhow::Result { let content = fs::read_to_string(path)?; - Ok(content.trim().parse::()?) + Ok(content.trim().parse()?) } fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { @@ -73,7 +73,7 @@ impl Cpu { }; // Has to match "cpu{N}". - let Ok(number) = cpu_prefix_removed.parse::() else { + let Ok(number) = cpu_prefix_removed.parse() else { continue; }; diff --git a/src/main.rs b/src/main.rs index 9902b79..68b929f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use anyhow::Context; use clap::Parser as _; use std::fmt::Write as _; use std::io::Write as _; +use std::path::PathBuf; use std::{io, process}; use yansi::Paint as _; @@ -29,57 +30,17 @@ enum Command { Info, /// Start the daemon. - Start, + Start { + /// The daemon config path. + #[arg(long, env = "SUPERFREQ_CONFIG")] + config: PathBuf, + }, /// Modify CPU attributes. - CpuSet { - /// The CPUs to apply the changes to. When unspecified, will be applied to all CPUs. - #[arg(short = 'c', long = "for")] - for_: Option>, - - /// Set the CPU governor. - #[arg(short = 'g', long)] - governor: Option, // TODO: Validate with clap for available governors. - - /// Set CPU Energy Performance Preference (EPP). Short form: --epp. - #[arg(short = 'p', long, alias = "epp")] - energy_performance_preference: Option, - - /// Set CPU Energy Performance Bias (EPB). Short form: --epb. - #[arg(short = 'b', long, alias = "epb")] - energy_performance_bias: Option, - - /// Set minimum CPU frequency in MHz. Short form: --freq-min. - #[arg(short = 'f', long, alias = "freq-min", value_parser = clap::value_parser!(u64).range(1..=10_000))] - frequency_mhz_minimum: Option, - - /// Set maximum CPU frequency in MHz. Short form: --freq-max. - #[arg(short = 'F', long, alias = "freq-max", value_parser = clap::value_parser!(u64).range(1..=10_000))] - frequency_mhz_maximum: Option, - - /// Set turbo boost behaviour. Has to be for all CPUs. - #[arg(short = 't', long, conflicts_with = "for_")] - turbo: Option, - }, + CpuSet(config::CpuDelta), /// Modify power supply attributes. - PowerSet { - /// The power supplies to apply the changes to. When unspecified, will be applied to all power supplies. - #[arg(short = 'p', long = "for")] - for_: Option>, - - /// Set the percentage that the power supply has to drop under for charging to start. Short form: --charge-start. - #[arg(short = 'c', long, alias = "charge-start", value_parser = clap::value_parser!(u8).range(0..=100))] - charge_threshold_start: Option, - - /// Set the percentage where charging will stop. Short form: --charge-end. - #[arg(short = 'C', long, alias = "charge-end", value_parser = clap::value_parser!(u8).range(0..=100))] - charge_threshold_end: Option, - - /// Set ACPI platform profile. Has to be for all power supplies. - #[arg(short = 'f', long, alias = "profile", conflicts_with = "for_")] - platform_profile: Option, - }, + PowerSet(config::PowerDelta), } fn real_main() -> anyhow::Result<()> { @@ -91,17 +52,17 @@ fn real_main() -> anyhow::Result<()> { .format_module_path(false) .init(); - let config = config::load_config().context("failed to load config")?; - match cli.command { Command::Info => todo!(), - Command::Start => { - daemon::run_daemon(config)?; - Ok(()) + Command::Start { config } => { + let config = config::DaemonConfig::load_from(&config) + .context("failed to load daemon config file")?; + + daemon::run(config) } - Command::CpuSet { + Command::CpuSet(config::CpuDelta { for_, governor, energy_performance_preference, @@ -109,7 +70,7 @@ fn real_main() -> anyhow::Result<()> { frequency_mhz_minimum, frequency_mhz_maximum, turbo, - } => { + }) => { let cpus = match for_ { Some(numbers) => { let mut cpus = Vec::with_capacity(numbers.len()); @@ -152,12 +113,12 @@ fn real_main() -> anyhow::Result<()> { Ok(()) } - Command::PowerSet { + Command::PowerSet(config::PowerDelta { for_, charge_threshold_start, charge_threshold_end, platform_profile, - } => { + }) => { let power_supplies = match for_ { Some(names) => { let mut power_supplies = Vec::with_capacity(names.len()); From 6349055c64d8ebc287d779af4164ba1c0f57844e Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 21:32:08 +0300 Subject: [PATCH 2/7] config: fix schema, toml does not have top level lists --- src/config.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0e07031..47de929 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,7 +111,9 @@ pub struct DaemonConfigLayer { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(transparent, default, rename_all = "kebab-case")] -pub struct DaemonConfig(pub Vec); +pub struct DaemonConfig { + config: Vec, +} impl DaemonConfig { pub fn load_from(path: &Path) -> anyhow::Result { @@ -122,9 +124,9 @@ impl DaemonConfig { let config: Self = toml::from_str(&contents).context("failed to parse config file")?; { - let mut priorities = Vec::with_capacity(config.0.len()); + let mut priorities = Vec::with_capacity(config.config.len()); - for layer in &config.0 { + for layer in &config.config { if priorities.contains(&layer.priority) { bail!("each config layer must have a different priority") } From 50168258024cacae3c49fca672b6ca6fa4bcbede Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 21:36:36 +0300 Subject: [PATCH 3/7] main: move application to deltas, comment out broken modules for now --- src/config.rs | 84 ++++++++++++++++++++++++++++++++++++++++ src/cpu.rs | 8 ++-- src/main.rs | 103 ++++---------------------------------------------- 3 files changed, 96 insertions(+), 99 deletions(-) diff --git a/src/config.rs b/src/config.rs index 47de929..cc9f18c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,8 @@ use std::{fs, path::Path}; use anyhow::{Context, bail}; use serde::{Deserialize, Serialize}; +use crate::{cpu, power_supply}; + fn is_default(value: &T) -> bool { *value == T::default() } @@ -46,6 +48,51 @@ pub struct CpuDelta { pub turbo: Option, } +impl CpuDelta { + pub fn apply(&self) -> anyhow::Result<()> { + let cpus = match &self.for_ { + Some(numbers) => { + let mut cpus = Vec::with_capacity(numbers.len()); + + for &number in numbers { + cpus.push(cpu::Cpu::new(number)?); + } + + cpus + } + None => cpu::Cpu::all()?, + }; + + for cpu in cpus { + if let Some(governor) = self.governor.as_ref() { + cpu.set_governor(governor)?; + } + + if let Some(epp) = self.energy_performance_preference.as_ref() { + cpu.set_epp(epp)?; + } + + if let Some(epb) = self.energy_performance_bias.as_ref() { + cpu.set_epb(epb)?; + } + + if let Some(mhz_minimum) = self.frequency_mhz_minimum { + cpu.set_frequency_minimum(mhz_minimum)?; + } + + if let Some(mhz_maximum) = self.frequency_mhz_maximum { + cpu.set_frequency_maximum(mhz_maximum)?; + } + } + + if let Some(turbo) = self.turbo { + cpu::Cpu::set_turbo(turbo)?; + } + + Ok(()) + } +} + #[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)] #[serde(deny_unknown_fields, default, rename_all = "kebab-case")] pub struct PowerDelta { @@ -70,6 +117,43 @@ pub struct PowerDelta { pub platform_profile: Option, } +impl PowerDelta { + pub fn apply(&self) -> anyhow::Result<()> { + let power_supplies = match &self.for_ { + Some(names) => { + let mut power_supplies = Vec::with_capacity(names.len()); + + for name in names { + power_supplies.push(power_supply::PowerSupply::from_name(name.clone())?); + } + + power_supplies + } + + None => power_supply::PowerSupply::all()? + .into_iter() + .filter(|power_supply| power_supply.threshold_config.is_some()) + .collect(), + }; + + for power_supply in power_supplies { + if let Some(threshold_start) = self.charge_threshold_start { + power_supply.set_charge_threshold_start(threshold_start)?; + } + + if let Some(threshold_end) = self.charge_threshold_end { + power_supply.set_charge_threshold_end(threshold_end)?; + } + } + + if let Some(platform_profile) = self.platform_profile.as_ref() { + power_supply::PowerSupply::set_platform_profile(platform_profile)?; + } + + Ok(()) + } +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Hash)] #[serde(untagged, rename_all = "kebab-case")] pub enum Condition { diff --git a/src/cpu.rs b/src/cpu.rs index d0985e9..4d9d551 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -236,7 +236,7 @@ impl Cpu { self.validate_frequency_minimum(frequency_mhz)?; // We use u64 for the intermediate calculation to prevent overflow - let frequency_khz = u64::from(frequency_mhz) * 1000; + let frequency_khz = frequency_mhz * 1000; let frequency_khz = frequency_khz.to_string(); write( @@ -258,7 +258,7 @@ impl Cpu { return Ok(()); }; - if new_frequency_mhz as u64 * 1000 < minimum_frequency_khz { + if new_frequency_mhz * 1000 < minimum_frequency_khz { bail!( "new minimum frequency ({new_frequency_mhz} MHz) cannot be lower than the minimum frequency ({} MHz) for {self}", minimum_frequency_khz / 1000, @@ -274,7 +274,7 @@ impl Cpu { self.validate_frequency_maximum(frequency_mhz)?; // We use u64 for the intermediate calculation to prevent overflow - let frequency_khz = u64::from(frequency_mhz) * 1000; + let frequency_khz = frequency_mhz * 1000; let frequency_khz = frequency_khz.to_string(); write( @@ -344,7 +344,7 @@ impl Cpu { let Cpu { number, .. } = cpu; write( - &format!("/sys/devices/system/cpu/cpu{number}/cpufreq/boost"), + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/boost"), value_boost, ) .is_ok() diff --git a/src/main.rs b/src/main.rs index 68b929f..52f33c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ mod config; -mod core; +// mod core; mod cpu; -mod daemon; -mod engine; -mod monitor; +// mod daemon; +// mod engine; +// mod monitor; mod power_supply; use anyhow::Context; @@ -56,102 +56,15 @@ fn real_main() -> anyhow::Result<()> { Command::Info => todo!(), Command::Start { config } => { - let config = config::DaemonConfig::load_from(&config) + let _config = config::DaemonConfig::load_from(&config) .context("failed to load daemon config file")?; - daemon::run(config) - } - - Command::CpuSet(config::CpuDelta { - for_, - governor, - energy_performance_preference, - energy_performance_bias, - frequency_mhz_minimum, - frequency_mhz_maximum, - turbo, - }) => { - let cpus = match for_ { - Some(numbers) => { - let mut cpus = Vec::with_capacity(numbers.len()); - - for number in numbers { - cpus.push(cpu::Cpu::new(number)?); - } - - cpus - } - None => cpu::Cpu::all()?, - }; - - for cpu in cpus { - if let Some(governor) = governor.as_ref() { - cpu.set_governor(governor)?; - } - - if let Some(epp) = energy_performance_preference.as_ref() { - cpu.set_epp(epp)?; - } - - if let Some(epb) = energy_performance_bias.as_ref() { - cpu.set_epb(epb)?; - } - - if let Some(mhz_minimum) = frequency_mhz_minimum { - cpu.set_frequency_minimum(mhz_minimum)?; - } - - if let Some(mhz_maximum) = frequency_mhz_maximum { - cpu.set_frequency_maximum(mhz_maximum)?; - } - } - - if let Some(turbo) = turbo { - cpu::Cpu::set_turbo(turbo)?; - } - + // daemon::run(config) Ok(()) } - Command::PowerSet(config::PowerDelta { - for_, - charge_threshold_start, - charge_threshold_end, - platform_profile, - }) => { - let power_supplies = match for_ { - Some(names) => { - let mut power_supplies = Vec::with_capacity(names.len()); - - for name in names { - power_supplies.push(power_supply::PowerSupply::from_name(name)?); - } - - power_supplies - } - - None => power_supply::PowerSupply::all()? - .into_iter() - .filter(|power_supply| power_supply.threshold_config.is_some()) - .collect(), - }; - - for power_supply in power_supplies { - if let Some(threshold_start) = charge_threshold_start { - power_supply.set_charge_threshold_start(threshold_start)?; - } - - if let Some(threshold_end) = charge_threshold_end { - power_supply.set_charge_threshold_end(threshold_end)?; - } - } - - if let Some(platform_profile) = platform_profile.as_ref() { - power_supply::PowerSupply::set_platform_profile(platform_profile); - } - - Ok(()) - } + Command::CpuSet(delta) => delta.apply(), + Command::PowerSet(delta) => delta.apply(), } } From 36e4bc05af67606d9876f844449c65fc2092b73c Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 21:44:27 +0300 Subject: [PATCH 4/7] power_supply&cpu: somewhat improve error messages --- src/config.rs | 2 +- src/cpu.rs | 11 +++++++---- src/power_supply.rs | 11 +++++------ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index cc9f18c..5d528b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,7 +60,7 @@ impl CpuDelta { cpus } - None => cpu::Cpu::all()?, + None => cpu::Cpu::all().context("failed to get all CPUs and their information")?, }; for cpu in cpus { diff --git a/src/cpu.rs b/src/cpu.rs index 4d9d551..ceac01d 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -92,10 +92,13 @@ impl Cpu { /// Rescan CPU, tuning local copy of settings. pub fn rescan(&mut self) -> anyhow::Result<()> { - let has_cpufreq = exists(format!( - "/sys/devices/system/cpu/cpu{number}/cpufreq", - number = self.number, - )); + let Self { number, .. } = self; + + if !exists(format!("/sys/devices/system/cpu/cpu{number}")) { + bail!("{self} does not exist"); + } + + let has_cpufreq = exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); self.has_cpufreq = has_cpufreq; diff --git a/src/power_supply.rs b/src/power_supply.rs index 9ec00ab..12649b8 100644 --- a/src/power_supply.rs +++ b/src/power_supply.rs @@ -62,12 +62,7 @@ pub struct PowerSupply { impl fmt::Display for PowerSupply { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "power supply '{name}' at '{path}'", - name = &self.name, - path = self.path.display(), - )?; + write!(f, "power supply '{name}'", name = &self.name)?; if let Some(config) = self.threshold_config.as_ref() { write!( @@ -147,6 +142,10 @@ impl PowerSupply { } pub fn rescan(&mut self) -> anyhow::Result<()> { + if !self.path.exists() { + bail!("{self} does not exist"); + } + let threshold_config = self .get_type() .with_context(|| format!("failed to determine what type of power supply '{self}' is"))? From 986e7e08b5cc566522dff8a4bf4d6a30bdca5796 Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 21:47:56 +0300 Subject: [PATCH 5/7] power_supply&cpu: kolor --- src/cpu.rs | 4 +++- src/power_supply.rs | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cpu.rs b/src/cpu.rs index ceac01d..17a5da1 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,4 +1,5 @@ use anyhow::{Context, bail}; +use yansi::Paint as _; use std::{fmt, fs, path::Path, string::ToString}; @@ -36,7 +37,8 @@ pub struct Cpu { impl fmt::Display for Cpu { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self { number, .. } = self; + let number = self.number.cyan(); + write!(f, "CPU {number}") } } diff --git a/src/power_supply.rs b/src/power_supply.rs index 12649b8..1f69a3c 100644 --- a/src/power_supply.rs +++ b/src/power_supply.rs @@ -1,4 +1,5 @@ use anyhow::{Context, bail}; +use yansi::Paint as _; use std::{ fmt, fs, @@ -62,13 +63,13 @@ pub struct PowerSupply { impl fmt::Display for PowerSupply { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "power supply '{name}'", name = &self.name)?; + write!(f, "power supply '{name}'", name = self.name.yellow())?; if let Some(config) = self.threshold_config.as_ref() { write!( f, " from manufacturer '{manufacturer}'", - manufacturer = config.manufacturer, + manufacturer = config.manufacturer.green(), )?; } From 923f759533336d2dcd508fb67765e1d66b3a27f8 Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 22:22:26 +0300 Subject: [PATCH 6/7] config: better more enhanched expression --- src/config.rs | 108 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index 5d528b8..f8fb584 100644 --- a/src/config.rs +++ b/src/config.rs @@ -154,38 +154,103 @@ impl PowerDelta { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(untagged, rename_all = "kebab-case")] -pub enum Condition { - ChargeLessThan(u8), - ChargeMoreThan(u8), +pub enum Expression { + #[serde(rename = "$cpu-temperature")] + CpuTemperature, - TemperatureLessThan(u8), - TemperatureMoreThan(u8), + #[serde(rename = "%cpu-volatility")] + CpuVolatility, - UtilizationLessThan(u8), - UtilizationMoreThan(u8), + #[serde(rename = "%cpu-utilization")] + CpuUtilization, + #[serde(rename = "%power-supply-charge")] + PowerSupplyCharge, + + #[serde(rename = "%power-supply-discharge-rate")] + PowerSupplyDischargeRate, + + #[serde(rename = "?charging")] Charging, + #[serde(rename = "?on-battery")] OnBattery, + #[serde(rename = "#false")] False, + #[default] + #[serde(rename = "#true")] True, - All(Vec), - Any(Vec), + Number(f64), - Not(Box), + Plus { + value: Box, + plus: Box, + }, + Minus { + value: Box, + minus: Box, + }, + Multiply { + value: Box, + multiply: Box, + }, + Power { + value: Box, + power: Box, + }, + Divide { + value: Box, + divide: Box, + }, + + LessThan { + value: Box, + is_less_than: Box, + }, + + MoreThan { + value: Box, + is_more_than: Box, + }, + + Equal { + value: Box, + is_equal: Box, + leeway: Box, + }, + + And { + value: Box, + and: Box, + }, + All { + all: Vec, + }, + + Or { + value: Box, + or: Box, + }, + Any { + any: Vec, + }, + + Not { + not: Box, + }, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct DaemonConfigLayer { +pub struct Rule { priority: u8, #[serde(default, skip_serializing_if = "is_default")] - if_: Condition, + if_: Expression, #[serde(default, skip_serializing_if = "is_default")] cpu: CpuDelta, @@ -193,10 +258,11 @@ pub struct DaemonConfigLayer { power: PowerDelta, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(transparent, default, rename_all = "kebab-case")] pub struct DaemonConfig { - config: Vec, + #[serde(rename = "rule")] + rules: Vec, } impl DaemonConfig { @@ -208,14 +274,14 @@ impl DaemonConfig { let config: Self = toml::from_str(&contents).context("failed to parse config file")?; { - let mut priorities = Vec::with_capacity(config.config.len()); + let mut priorities = Vec::with_capacity(config.rules.len()); - for layer in &config.config { - if priorities.contains(&layer.priority) { - bail!("each config layer must have a different priority") + for rule in &config.rules { + if priorities.contains(&rule.priority) { + bail!("each config rule must have a different priority") } - priorities.push(layer.priority); + priorities.push(rule.priority); } } From a5151f475bebbdea52fb9e9a0ad0e433e67579ab Mon Sep 17 00:00:00 2001 From: RGBCube Date: Mon, 19 May 2025 22:32:54 +0300 Subject: [PATCH 7/7] daemon: wip new impl --- src/config.rs | 14 +- src/daemon.rs | 771 +++++++++++----------------------------------- src/daemon_old.rs | 649 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +- 4 files changed, 834 insertions(+), 607 deletions(-) create mode 100644 src/daemon_old.rs diff --git a/src/config.rs b/src/config.rs index f8fb584..585b7a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -157,14 +157,20 @@ impl PowerDelta { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(untagged, rename_all = "kebab-case")] pub enum Expression { + #[serde(rename = "%cpu-usage")] + CpuUsage, + + #[serde(rename = "$cpu-usage-volatility")] + CpuUsageVolatility, + #[serde(rename = "$cpu-temperature")] CpuTemperature, - #[serde(rename = "%cpu-volatility")] - CpuVolatility, + #[serde(rename = "$cpu-temperature-volatility")] + CpuTemperatureVolatility, - #[serde(rename = "%cpu-utilization")] - CpuUtilization, + #[serde(rename = "$cpu-idle-seconds")] + CpuIdleSeconds, #[serde(rename = "%power-supply-charge")] PowerSupplyCharge, diff --git a/src/daemon.rs b/src/daemon.rs index ba6d37d..55241a5 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,649 +1,222 @@ -use anyhow::Context; -use anyhow::bail; +use std::{ + collections::VecDeque, + ops, + time::{Duration, Instant}, +}; -use crate::config::AppConfig; -use crate::core::SystemReport; -use crate::engine; -use crate::monitor; -use std::collections::VecDeque; -use std::fs::File; -use std::io::Write; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; +use crate::config; -/// Parameters for computing optimal polling interval -struct IntervalParams { - /// Base polling interval in seconds - base_interval: u64, - /// Minimum allowed polling interval in seconds - min_interval: u64, - /// Maximum allowed polling interval in seconds - max_interval: u64, - /// How rapidly CPU usage is changing - cpu_volatility: f32, - /// How rapidly temperature is changing - temp_volatility: f32, - /// Battery discharge rate in %/hour if available - battery_discharge_rate: Option, - /// Time since last detected user activity - last_user_activity: Duration, - /// Whether the system appears to be idle - is_system_idle: bool, - /// Whether the system is running on battery power - on_battery: bool, -} - -/// Calculate the idle time multiplier based on system idle duration +/// Calculate the idle time multiplier based on system idle time. /// -/// Returns a multiplier between 1.0 and 5.0 (capped): +/// Returns a multiplier between 1.0 and 5.0: /// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0 /// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes)) -fn idle_multiplier(idle_secs: u64) -> f32 { - if idle_secs == 0 { - return 1.0; // No idle time, no multiplier effect - } - - let idle_factor = if idle_secs < 120 { - // Less than 2 minutes (0 to 119 seconds) +fn idle_multiplier(idle_for: Duration) -> f64 { + let factor = match idle_for.as_secs() < 120 { + // Less than 2 minutes. // Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s) - 1.0 + (idle_secs as f32) / 120.0 - } else { - // 2 minutes (120 seconds) or more - let idle_time_minutes = idle_secs / 60; + true => (idle_for.as_secs() as f64) / 120.0, + + // 2 minutes or more. // Logarithmic scaling: 1.0 + log2(minutes) - 1.0 + (idle_time_minutes as f32).log2().max(0.5) + false => { + let idle_minutes = idle_for.as_secs() as f64 / 60.0; + idle_minutes.log2() + } }; - // Cap the multiplier to avoid excessive intervals - idle_factor.min(5.0) // max factor of 5x + // Clamp the multiplier to avoid excessive intervals. + (1.0 + factor).clamp(1.0, 5.0) } -/// Calculate optimal polling interval based on system conditions and history -/// -/// Returns Ok with the calculated interval, or Err if the configuration is invalid -fn compute_new(params: &IntervalParams, system_history: &SystemHistory) -> anyhow::Result { - // Use the centralized validation function - validate_poll_intervals(params.min_interval, params.max_interval)?; - - // Start with base interval - let mut adjusted_interval = params.base_interval; - - // If we're on battery, we want to be more aggressive about saving power - if params.on_battery { - // Apply a multiplier based on battery discharge rate - if let Some(discharge_rate) = params.battery_discharge_rate { - if discharge_rate > 20.0 { - // High discharge rate - increase polling interval significantly (3x) - adjusted_interval = adjusted_interval.saturating_mul(3); - } else if discharge_rate > 10.0 { - // Moderate discharge - double polling interval (2x) - adjusted_interval = adjusted_interval.saturating_mul(2); - } else { - // Low discharge rate - increase by 50% (multiply by 3/2) - adjusted_interval = adjusted_interval.saturating_mul(3).saturating_div(2); - } - } else { - // If we don't know discharge rate, use a conservative multiplier (2x) - adjusted_interval = adjusted_interval.saturating_mul(2); - } - } - - // Adjust for system idleness - if params.is_system_idle { - let idle_time_seconds = params.last_user_activity.as_secs(); - - // Apply adjustment only if the system has been idle for a non-zero duration - if idle_time_seconds > 0 { - let idle_factor = idle_multiplier(idle_time_seconds); - - log::debug!( - "System idle for {} seconds (approx. {} minutes), applying idle factor: {:.2}x", - idle_time_seconds, - (idle_time_seconds as f32 / 60.0).round(), - idle_factor - ); - - // Convert f32 multiplier to integer-safe math - // Multiply by a large number first, then divide to maintain precision - // Use 1000 as the scaling factor to preserve up to 3 decimal places - let scaling_factor = 1000; - let scaled_factor = (idle_factor * scaling_factor as f32) as u64; - adjusted_interval = adjusted_interval - .saturating_mul(scaled_factor) - .saturating_div(scaling_factor); - } - // If idle_time_seconds is 0, no factor is applied by this block - } - - // Adjust for CPU/temperature volatility - if params.cpu_volatility > 10.0 || params.temp_volatility > 2.0 { - // For division by 2 (halving the interval), we can safely use integer division - adjusted_interval = (adjusted_interval / 2).max(1); - } - - // Enforce a minimum of 1 second to prevent busy loops, regardless of params.min_interval - let min_safe_interval = params.min_interval.max(1); - let new_interval = adjusted_interval.clamp(min_safe_interval, params.max_interval); - - // Blend the new interval with the cached value if available - let blended_interval = if let Some(cached) = system_history.last_computed_interval { - // Use a weighted average: 70% previous value, 30% new value - // This smooths out drastic changes in polling frequency - const PREVIOUS_VALUE_WEIGHT: u128 = 7; // 70% - const NEW_VALUE_WEIGHT: u128 = 3; // 30% - const TOTAL_WEIGHT: u128 = PREVIOUS_VALUE_WEIGHT + NEW_VALUE_WEIGHT; // 10 - - // XXX: Use u128 arithmetic to avoid overflow with large interval values - let result = (u128::from(cached) * PREVIOUS_VALUE_WEIGHT - + u128::from(new_interval) * NEW_VALUE_WEIGHT) - / TOTAL_WEIGHT; - - result as u64 - } else { - new_interval - }; - - // Blended result still needs to respect the configured bounds - // Again enforce minimum of 1 second regardless of params.min_interval - Ok(blended_interval.clamp(min_safe_interval, params.max_interval)) -} - -/// Tracks historical system data for "advanced" adaptive polling -#[derive(Debug)] -struct SystemHistory { - /// Last several CPU usage measurements - cpu_usage_history: VecDeque, - /// Last several temperature readings - temperature_history: VecDeque, - /// Time of last detected user activity +struct Daemon { + /// Last time when there was user activity. last_user_activity: Instant, - /// Previous battery percentage (to calculate discharge rate) - last_battery_percentage: Option, - /// Timestamp of last battery reading - last_battery_timestamp: Option, - /// Battery discharge rate (%/hour) - battery_discharge_rate: Option, - /// Time spent in each system state - state_durations: std::collections::HashMap, - /// Last time a state transition happened - last_state_change: Instant, - /// Current system state - current_state: SystemState, - /// Last computed optimal polling interval - last_computed_interval: Option, + + /// CPU usage and temperature log. + cpu_log: VecDeque, + + /// Power supply status log. + power_supply_log: VecDeque, + + charging: bool, } -impl Default for SystemHistory { - fn default() -> Self { - Self { - cpu_usage_history: VecDeque::new(), - temperature_history: VecDeque::new(), - last_user_activity: Instant::now(), - last_battery_percentage: None, - last_battery_timestamp: None, - battery_discharge_rate: None, - state_durations: std::collections::HashMap::new(), - last_state_change: Instant::now(), - current_state: SystemState::default(), - last_computed_interval: None, - } - } +struct CpuLog { + at: Instant, + + /// CPU usage between 0-1, a percentage. + usage: f64, + + /// CPU temperature in celcius. + temperature: f64, } -impl SystemHistory { - /// Update system history with new report data - fn update(&mut self, report: &SystemReport) { - // Update CPU usage history - if !report.cpu_cores.is_empty() { - let mut total_usage: f32 = 0.0; - let mut core_count: usize = 0; +struct CpuVolatility { + at: ops::Range, - for core in &report.cpu_cores { - if let Some(usage) = core.usage_percent { - total_usage += usage; - core_count += 1; - } - } + usage: f64, - if core_count > 0 { - let avg_usage = total_usage / core_count as f32; + temperature: f64, +} - // Keep only the last 5 measurements - if self.cpu_usage_history.len() >= 5 { - self.cpu_usage_history.pop_front(); - } - self.cpu_usage_history.push_back(avg_usage); - - // Update last_user_activity if CPU usage indicates activity - // Consider significant CPU usage or sudden change as user activity - if avg_usage > 20.0 - || (self.cpu_usage_history.len() > 1 - && (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2]) - .abs() - > 15.0) - { - self.last_user_activity = Instant::now(); - log::debug!("User activity detected based on CPU usage"); - } - } +impl Daemon { + fn cpu_volatility(&self) -> Option { + if self.cpu_log.len() < 2 { + return None; } - // Update temperature history - if let Some(temp) = report.cpu_global.average_temperature_celsius { - if self.temperature_history.len() >= 5 { - self.temperature_history.pop_front(); - } - self.temperature_history.push_back(temp); + let change_count = self.cpu_log.len() - 1; - // Significant temperature increase can indicate user activity - if self.temperature_history.len() > 1 { - let temp_change = - temp - self.temperature_history[self.temperature_history.len() - 2]; - if temp_change > 5.0 { - // 5°C rise in temperature - self.last_user_activity = Instant::now(); - log::debug!("User activity detected based on temperature change"); - } - } + let mut usage_change_sum = 0.0; + let mut temperature_change_sum = 0.0; + + for index in 0..change_count { + let usage_change = self.cpu_log[index + 1].usage - self.cpu_log[index].usage; + usage_change_sum += usage_change.abs(); + + let temperature_change = + self.cpu_log[index + 1].temperature - self.cpu_log[index].temperature; + temperature_change_sum += temperature_change.abs(); } - // Update battery discharge rate - if let Some(battery) = report.batteries.first() { - // Reset when we are charging or have just connected AC - if battery.ac_connected { - // Reset discharge tracking but continue updating the rest of - // the history so we still detect activity/load changes on AC. - self.battery_discharge_rate = None; - self.last_battery_percentage = None; - self.last_battery_timestamp = None; - } + Some(CpuVolatility { + at: self.cpu_log.front().unwrap().at..self.cpu_log.back().unwrap().at, - if let Some(current_percentage) = battery.capacity_percent { - let current_percent = f32::from(current_percentage); - - if let (Some(last_percentage), Some(last_timestamp)) = - (self.last_battery_percentage, self.last_battery_timestamp) - { - let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0; - // Only calculate discharge rate if at least 30 seconds have passed - // and we're not on AC power - if elapsed_hours > 0.0083 && !battery.ac_connected { - // 0.0083 hours = 30 seconds - // Calculate discharge rate in percent per hour - let percent_change = last_percentage - current_percent; - if percent_change > 0.0 { - // Only if battery is discharging - let hourly_rate = percent_change / elapsed_hours; - // Clamp the discharge rate to a reasonable maximum value (100%/hour) - let clamped_rate = hourly_rate.min(100.0); - self.battery_discharge_rate = Some(clamped_rate); - } - } - } - - self.last_battery_percentage = Some(current_percent); - self.last_battery_timestamp = Some(Instant::now()); - } - } - - // Update system state tracking - let new_state = determine_system_state(report, self); - if new_state != self.current_state { - // Record time spent in previous state - let time_in_state = self.last_state_change.elapsed(); - *self - .state_durations - .entry(self.current_state.clone()) - .or_insert(Duration::ZERO) += time_in_state; - - // State changes (except to Idle) likely indicate user activity - if new_state != SystemState::Idle && new_state != SystemState::LowLoad { - self.last_user_activity = Instant::now(); - log::debug!("User activity detected based on system state change to {new_state:?}"); - } - - // Update state - self.current_state = new_state; - self.last_state_change = Instant::now(); - } - - // Check for significant load changes - if report.system_load.load_avg_1min > 1.0 { - self.last_user_activity = Instant::now(); - log::debug!("User activity detected based on system load"); - } + usage: usage_change_sum / change_count as f64, + temperature: temperature_change_sum / change_count as f64, + }) } - /// Calculate CPU usage volatility (how much it's changing) - fn get_cpu_volatility(&self) -> f32 { - if self.cpu_usage_history.len() < 2 { - return 0.0; - } + fn is_cpu_idle(&self) -> bool { + let recent_log_count = self + .cpu_log + .iter() + .rev() + .take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60)) + .count(); - let mut sum_of_changes = 0.0; - for i in 1..self.cpu_usage_history.len() { - sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs(); - } - - sum_of_changes / (self.cpu_usage_history.len() - 1) as f32 - } - - /// Calculate temperature volatility - fn get_temperature_volatility(&self) -> f32 { - if self.temperature_history.len() < 2 { - return 0.0; - } - - let mut sum_of_changes = 0.0; - for i in 1..self.temperature_history.len() { - sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs(); - } - - sum_of_changes / (self.temperature_history.len() - 1) as f32 - } - - /// Determine if the system appears to be idle - fn is_system_idle(&self) -> bool { - if self.cpu_usage_history.is_empty() { + if recent_log_count < 2 { return false; } - // System considered idle if the average CPU usage of last readings is below 10% - let recent_avg = - self.cpu_usage_history.iter().sum::() / self.cpu_usage_history.len() as f32; - recent_avg < 10.0 && self.get_cpu_volatility() < 5.0 - } + let recent_average = self + .cpu_log + .iter() + .rev() + .take(recent_log_count) + .map(|log| log.usage) + .sum::() + / recent_log_count as f64; - /// Calculate optimal polling interval based on system conditions - fn calculate_optimal_interval( - &self, - config: &AppConfig, - on_battery: bool, - ) -> anyhow::Result { - let params = IntervalParams { - base_interval: config.daemon.poll_interval_sec, - min_interval: config.daemon.min_poll_interval_sec, - max_interval: config.daemon.max_poll_interval_sec, - cpu_volatility: self.get_cpu_volatility(), - temp_volatility: self.get_temperature_volatility(), - battery_discharge_rate: self.battery_discharge_rate, - last_user_activity: self.last_user_activity.elapsed(), - is_system_idle: self.is_system_idle(), - on_battery, - }; - - compute_new(¶ms, self) + recent_average < 0.1 + && self + .cpu_volatility() + .is_none_or(|volatility| volatility.usage < 0.05) } } -/// Validates that poll interval configuration is consistent -/// Returns Ok if configuration is valid, Err with a descriptive message if invalid -fn validate_poll_intervals(min_interval: u64, max_interval: u64) -> anyhow::Result<()> { - if min_interval < 1 { - bail!("min_interval must be ≥ 1"); - } - if max_interval < 1 { - bail!("max_interval must be ≥ 1"); - } - if max_interval >= min_interval { - Ok(()) - } else { - bail!( - "Invalid interval configuration: max_interval ({max_interval}) is less than min_interval ({min_interval})" - ); +struct PowerSupplyLog { + at: Instant, + + /// Charge 0-1, as a percentage. + charge: f64, +} + +impl Daemon { + /// Calculates the discharge rate, returns a number between 0 and 1. + /// + /// The discharge rate is averaged per hour. + /// So a return value of Some(0.3) means the battery has been + /// discharging 30% per hour. + fn power_supply_discharge_rate(&self) -> Option { + let mut last_charge = None; + + // A list of increasing charge percentages. + let discharging: Vec<&PowerSupplyLog> = self + .power_supply_log + .iter() + .rev() + .take_while(move |log| { + let Some(last_charge_value) = last_charge else { + last_charge = Some(log.charge); + return true; + }; + + last_charge = Some(log.charge); + + log.charge > last_charge_value + }) + .collect(); + + if discharging.len() < 2 { + return None; + } + + // Start of discharging. Has the most charge. + let start = discharging.last().unwrap(); + // End of discharging, very close to now. Has the least charge. + let end = discharging.first().unwrap(); + + let discharging_duration_seconds = (start.at - end.at).as_secs_f64(); + let discharging_duration_hours = discharging_duration_seconds / 60.0 / 60.0; + let discharged = start.charge - end.charge; + + Some(discharged / discharging_duration_hours) } } -/// Run the daemon -pub fn run_daemon(config: AppConfig) -> anyhow::Result<()> { - log::info!("Starting superfreq daemon..."); +impl Daemon { + fn polling_interval(&self) -> Duration { + let mut interval = Duration::from_secs(5); - // Validate critical configuration values before proceeding - validate_poll_intervals( - config.daemon.min_poll_interval_sec, - config.daemon.max_poll_interval_sec, - )?; - - // Create a flag that will be set to true when a signal is received - let running = Arc::new(AtomicBool::new(true)); - let r = running.clone(); - - // Set up signal handlers - ctrlc::set_handler(move || { - log::info!("Received shutdown signal, exiting..."); - r.store(false, Ordering::SeqCst); - }) - .context("failed to set Ctrl-C handler")?; - - log::info!( - "Daemon initialized with poll interval: {}s", - config.daemon.poll_interval_sec - ); - - // Set up stats file if configured - if let Some(stats_path) = &config.daemon.stats_file_path { - log::info!("Stats will be written to: {stats_path}"); - } - - // Variables for adaptive polling - // Make sure that the poll interval is *never* zero to prevent a busy loop - let mut current_poll_interval = config.daemon.poll_interval_sec.max(1); - if config.daemon.poll_interval_sec == 0 { - log::warn!( - "Poll interval is set to zero in config, using 1s minimum to prevent a busy loop" - ); - } - let mut system_history = SystemHistory::default(); - - // Main loop - while running.load(Ordering::SeqCst) { - let start_time = Instant::now(); - - match monitor::collect_system_report(&config) { - Ok(report) => { - log::debug!("Collected system report, applying settings..."); - - // Store the current state before updating history - let previous_state = system_history.current_state.clone(); - - // Update system history with new data - system_history.update(&report); - - // Update the stats file if configured - if let Some(stats_path) = &config.daemon.stats_file_path { - if let Err(e) = write_stats_file(stats_path, &report) { - log::error!("Failed to write stats file: {e}"); - } - } - - match engine::determine_and_apply_settings(&report, &config, None) { - Ok(()) => { - log::debug!("Successfully applied system settings"); - - // If system state changed, log the new state - if system_history.current_state != previous_state { - log::info!( - "System state changed to: {:?}", - system_history.current_state - ); - } - } - Err(e) => { - log::error!("Error applying system settings: {e}"); - } - } - - // Check if we're on battery - let on_battery = !report.batteries.is_empty() - && report.batteries.first().is_some_and(|b| !b.ac_connected); - - // Calculate optimal polling interval if adaptive polling is enabled - if config.daemon.adaptive_interval { - match system_history.calculate_optimal_interval(&config, on_battery) { - Ok(optimal_interval) => { - // Store the new interval - system_history.last_computed_interval = Some(optimal_interval); - - log::debug!("Recalculated optimal interval: {optimal_interval}s"); - - // Don't change the interval too dramatically at once - match optimal_interval.cmp(¤t_poll_interval) { - std::cmp::Ordering::Greater => { - current_poll_interval = - (current_poll_interval + optimal_interval) / 2; - } - std::cmp::Ordering::Less => { - current_poll_interval = current_poll_interval - - ((current_poll_interval - optimal_interval) / 2).max(1); - } - std::cmp::Ordering::Equal => { - // No change needed when they're equal - } - } - } - Err(e) => { - // Log the error and stop the daemon when an invalid configuration is detected - log::error!("Critical configuration error: {e}"); - running.store(false, Ordering::SeqCst); - break; - } - } - - // Make sure that we respect the (user) configured min and max limits - current_poll_interval = current_poll_interval.clamp( - config.daemon.min_poll_interval_sec, - config.daemon.max_poll_interval_sec, - ); - - log::debug!("Adaptive polling: set interval to {current_poll_interval}s"); - } else { - // If adaptive polling is disabled, still apply battery-saving adjustment - if config.daemon.throttle_on_battery && on_battery { - let battery_multiplier = 2; // poll half as often on battery - - // We need to make sure `poll_interval_sec` is *at least* 1 - // before multiplying. - let safe_interval = config.daemon.poll_interval_sec.max(1); - current_poll_interval = (safe_interval * battery_multiplier) - .min(config.daemon.max_poll_interval_sec); - - log::debug!( - "On battery power, increased poll interval to {current_poll_interval}s" - ); + // We are on battery, so we must be more conservative with our polling. + if !self.charging { + match self.power_supply_discharge_rate() { + Some(discharge_rate) => { + if discharge_rate > 0.2 { + interval *= 3; + } else if discharge_rate > 0.1 { + interval *= 2; } else { - // Use the configured poll interval - current_poll_interval = config.daemon.poll_interval_sec.max(1); - if config.daemon.poll_interval_sec == 0 { - log::debug!( - "Using minimum poll interval of 1s instead of configured 0s" - ); - } + // *= 1.5; + interval /= 2; + interval *= 3; } } - } - Err(e) => { - log::error!("Error collecting system report: {e}"); + + None => { + interval *= 2; + } } } - // Sleep for the remaining time in the poll interval - let elapsed = start_time.elapsed(); - let poll_duration = Duration::from_secs(current_poll_interval); - if elapsed < poll_duration { - let sleep_time = poll_duration - elapsed; - log::debug!("Sleeping for {}s until next cycle", sleep_time.as_secs()); - std::thread::sleep(sleep_time); + if self.is_cpu_idle() { + let idle_for = self.last_user_activity.elapsed(); + + if idle_for > Duration::from_secs(30) { + let factor = idle_multiplier(idle_for); + + log::debug!( + "system has been idle for {seconds} seconds (approx {minutes} minutes), applying idle factor: {factor:.2}x", + seconds = idle_for.as_secs(), + minutes = idle_for.as_secs() / 60, + ); + + interval = Duration::from_secs_f64(interval.as_secs_f64() * factor); + } } + + if let Some(volatility) = self.cpu_volatility() { + if volatility.usage > 0.1 || volatility.temperature > 0.02 { + interval = (interval / 2).max(Duration::from_secs(1)); + } + } + + todo!("implement rest from daemon_old.rs") } +} - log::info!("Daemon stopped"); +pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { Ok(()) } - -/// Write current system stats to a file for --stats to read -fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Error> { - let mut file = File::create(path)?; - - writeln!(file, "timestamp={:?}", report.timestamp)?; - - // CPU info - writeln!(file, "governor={:?}", report.cpu_global.current_governor)?; - writeln!(file, "turbo={:?}", report.cpu_global.turbo_status)?; - if let Some(temp) = report.cpu_global.average_temperature_celsius { - writeln!(file, "cpu_temp={temp:.1}")?; - } - - // Battery info - if !report.batteries.is_empty() { - let battery = &report.batteries[0]; - writeln!(file, "ac_power={}", battery.ac_connected)?; - if let Some(cap) = battery.capacity_percent { - writeln!(file, "battery_percent={cap}")?; - } - } - - // System load - writeln!(file, "load_1m={:.2}", report.system_load.load_avg_1min)?; - writeln!(file, "load_5m={:.2}", report.system_load.load_avg_5min)?; - writeln!(file, "load_15m={:.2}", report.system_load.load_avg_15min)?; - - Ok(()) -} - -/// Simplified system state used for determining when to adjust polling interval -#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)] -enum SystemState { - #[default] - Unknown, - OnAC, - OnBattery, - HighLoad, - LowLoad, - HighTemp, - Idle, -} - -/// Determine the current system state for adaptive polling -fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState { - // Check power state first - if !report.batteries.is_empty() { - if let Some(battery) = report.batteries.first() { - if battery.ac_connected { - return SystemState::OnAC; - } - return SystemState::OnBattery; - } - } - - // No batteries means desktop, so always AC - if report.batteries.is_empty() { - return SystemState::OnAC; - } - - // Check temperature - if let Some(temp) = report.cpu_global.average_temperature_celsius { - if temp > 80.0 { - return SystemState::HighTemp; - } - } - - // Check load first, as high load should take precedence over idle state - let avg_load = report.system_load.load_avg_1min; - if avg_load > 3.0 { - return SystemState::HighLoad; - } - - // Check idle state only if we don't have high load - if history.is_system_idle() { - return SystemState::Idle; - } - - // Check for low load - if avg_load < 0.5 { - return SystemState::LowLoad; - } - - // Default case - SystemState::Unknown -} diff --git a/src/daemon_old.rs b/src/daemon_old.rs new file mode 100644 index 0000000..ba6d37d --- /dev/null +++ b/src/daemon_old.rs @@ -0,0 +1,649 @@ +use anyhow::Context; +use anyhow::bail; + +use crate::config::AppConfig; +use crate::core::SystemReport; +use crate::engine; +use crate::monitor; +use std::collections::VecDeque; +use std::fs::File; +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Parameters for computing optimal polling interval +struct IntervalParams { + /// Base polling interval in seconds + base_interval: u64, + /// Minimum allowed polling interval in seconds + min_interval: u64, + /// Maximum allowed polling interval in seconds + max_interval: u64, + /// How rapidly CPU usage is changing + cpu_volatility: f32, + /// How rapidly temperature is changing + temp_volatility: f32, + /// Battery discharge rate in %/hour if available + battery_discharge_rate: Option, + /// Time since last detected user activity + last_user_activity: Duration, + /// Whether the system appears to be idle + is_system_idle: bool, + /// Whether the system is running on battery power + on_battery: bool, +} + +/// Calculate the idle time multiplier based on system idle duration +/// +/// Returns a multiplier between 1.0 and 5.0 (capped): +/// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0 +/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes)) +fn idle_multiplier(idle_secs: u64) -> f32 { + if idle_secs == 0 { + return 1.0; // No idle time, no multiplier effect + } + + let idle_factor = if idle_secs < 120 { + // Less than 2 minutes (0 to 119 seconds) + // Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s) + 1.0 + (idle_secs as f32) / 120.0 + } else { + // 2 minutes (120 seconds) or more + let idle_time_minutes = idle_secs / 60; + // Logarithmic scaling: 1.0 + log2(minutes) + 1.0 + (idle_time_minutes as f32).log2().max(0.5) + }; + + // Cap the multiplier to avoid excessive intervals + idle_factor.min(5.0) // max factor of 5x +} + +/// Calculate optimal polling interval based on system conditions and history +/// +/// Returns Ok with the calculated interval, or Err if the configuration is invalid +fn compute_new(params: &IntervalParams, system_history: &SystemHistory) -> anyhow::Result { + // Use the centralized validation function + validate_poll_intervals(params.min_interval, params.max_interval)?; + + // Start with base interval + let mut adjusted_interval = params.base_interval; + + // If we're on battery, we want to be more aggressive about saving power + if params.on_battery { + // Apply a multiplier based on battery discharge rate + if let Some(discharge_rate) = params.battery_discharge_rate { + if discharge_rate > 20.0 { + // High discharge rate - increase polling interval significantly (3x) + adjusted_interval = adjusted_interval.saturating_mul(3); + } else if discharge_rate > 10.0 { + // Moderate discharge - double polling interval (2x) + adjusted_interval = adjusted_interval.saturating_mul(2); + } else { + // Low discharge rate - increase by 50% (multiply by 3/2) + adjusted_interval = adjusted_interval.saturating_mul(3).saturating_div(2); + } + } else { + // If we don't know discharge rate, use a conservative multiplier (2x) + adjusted_interval = adjusted_interval.saturating_mul(2); + } + } + + // Adjust for system idleness + if params.is_system_idle { + let idle_time_seconds = params.last_user_activity.as_secs(); + + // Apply adjustment only if the system has been idle for a non-zero duration + if idle_time_seconds > 0 { + let idle_factor = idle_multiplier(idle_time_seconds); + + log::debug!( + "System idle for {} seconds (approx. {} minutes), applying idle factor: {:.2}x", + idle_time_seconds, + (idle_time_seconds as f32 / 60.0).round(), + idle_factor + ); + + // Convert f32 multiplier to integer-safe math + // Multiply by a large number first, then divide to maintain precision + // Use 1000 as the scaling factor to preserve up to 3 decimal places + let scaling_factor = 1000; + let scaled_factor = (idle_factor * scaling_factor as f32) as u64; + adjusted_interval = adjusted_interval + .saturating_mul(scaled_factor) + .saturating_div(scaling_factor); + } + // If idle_time_seconds is 0, no factor is applied by this block + } + + // Adjust for CPU/temperature volatility + if params.cpu_volatility > 10.0 || params.temp_volatility > 2.0 { + // For division by 2 (halving the interval), we can safely use integer division + adjusted_interval = (adjusted_interval / 2).max(1); + } + + // Enforce a minimum of 1 second to prevent busy loops, regardless of params.min_interval + let min_safe_interval = params.min_interval.max(1); + let new_interval = adjusted_interval.clamp(min_safe_interval, params.max_interval); + + // Blend the new interval with the cached value if available + let blended_interval = if let Some(cached) = system_history.last_computed_interval { + // Use a weighted average: 70% previous value, 30% new value + // This smooths out drastic changes in polling frequency + const PREVIOUS_VALUE_WEIGHT: u128 = 7; // 70% + const NEW_VALUE_WEIGHT: u128 = 3; // 30% + const TOTAL_WEIGHT: u128 = PREVIOUS_VALUE_WEIGHT + NEW_VALUE_WEIGHT; // 10 + + // XXX: Use u128 arithmetic to avoid overflow with large interval values + let result = (u128::from(cached) * PREVIOUS_VALUE_WEIGHT + + u128::from(new_interval) * NEW_VALUE_WEIGHT) + / TOTAL_WEIGHT; + + result as u64 + } else { + new_interval + }; + + // Blended result still needs to respect the configured bounds + // Again enforce minimum of 1 second regardless of params.min_interval + Ok(blended_interval.clamp(min_safe_interval, params.max_interval)) +} + +/// Tracks historical system data for "advanced" adaptive polling +#[derive(Debug)] +struct SystemHistory { + /// Last several CPU usage measurements + cpu_usage_history: VecDeque, + /// Last several temperature readings + temperature_history: VecDeque, + /// Time of last detected user activity + last_user_activity: Instant, + /// Previous battery percentage (to calculate discharge rate) + last_battery_percentage: Option, + /// Timestamp of last battery reading + last_battery_timestamp: Option, + /// Battery discharge rate (%/hour) + battery_discharge_rate: Option, + /// Time spent in each system state + state_durations: std::collections::HashMap, + /// Last time a state transition happened + last_state_change: Instant, + /// Current system state + current_state: SystemState, + /// Last computed optimal polling interval + last_computed_interval: Option, +} + +impl Default for SystemHistory { + fn default() -> Self { + Self { + cpu_usage_history: VecDeque::new(), + temperature_history: VecDeque::new(), + last_user_activity: Instant::now(), + last_battery_percentage: None, + last_battery_timestamp: None, + battery_discharge_rate: None, + state_durations: std::collections::HashMap::new(), + last_state_change: Instant::now(), + current_state: SystemState::default(), + last_computed_interval: None, + } + } +} + +impl SystemHistory { + /// Update system history with new report data + fn update(&mut self, report: &SystemReport) { + // Update CPU usage history + if !report.cpu_cores.is_empty() { + let mut total_usage: f32 = 0.0; + let mut core_count: usize = 0; + + for core in &report.cpu_cores { + if let Some(usage) = core.usage_percent { + total_usage += usage; + core_count += 1; + } + } + + if core_count > 0 { + let avg_usage = total_usage / core_count as f32; + + // Keep only the last 5 measurements + if self.cpu_usage_history.len() >= 5 { + self.cpu_usage_history.pop_front(); + } + self.cpu_usage_history.push_back(avg_usage); + + // Update last_user_activity if CPU usage indicates activity + // Consider significant CPU usage or sudden change as user activity + if avg_usage > 20.0 + || (self.cpu_usage_history.len() > 1 + && (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2]) + .abs() + > 15.0) + { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on CPU usage"); + } + } + } + + // Update temperature history + if let Some(temp) = report.cpu_global.average_temperature_celsius { + if self.temperature_history.len() >= 5 { + self.temperature_history.pop_front(); + } + self.temperature_history.push_back(temp); + + // Significant temperature increase can indicate user activity + if self.temperature_history.len() > 1 { + let temp_change = + temp - self.temperature_history[self.temperature_history.len() - 2]; + if temp_change > 5.0 { + // 5°C rise in temperature + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on temperature change"); + } + } + } + + // Update battery discharge rate + if let Some(battery) = report.batteries.first() { + // Reset when we are charging or have just connected AC + if battery.ac_connected { + // Reset discharge tracking but continue updating the rest of + // the history so we still detect activity/load changes on AC. + self.battery_discharge_rate = None; + self.last_battery_percentage = None; + self.last_battery_timestamp = None; + } + + if let Some(current_percentage) = battery.capacity_percent { + let current_percent = f32::from(current_percentage); + + if let (Some(last_percentage), Some(last_timestamp)) = + (self.last_battery_percentage, self.last_battery_timestamp) + { + let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0; + // Only calculate discharge rate if at least 30 seconds have passed + // and we're not on AC power + if elapsed_hours > 0.0083 && !battery.ac_connected { + // 0.0083 hours = 30 seconds + // Calculate discharge rate in percent per hour + let percent_change = last_percentage - current_percent; + if percent_change > 0.0 { + // Only if battery is discharging + let hourly_rate = percent_change / elapsed_hours; + // Clamp the discharge rate to a reasonable maximum value (100%/hour) + let clamped_rate = hourly_rate.min(100.0); + self.battery_discharge_rate = Some(clamped_rate); + } + } + } + + self.last_battery_percentage = Some(current_percent); + self.last_battery_timestamp = Some(Instant::now()); + } + } + + // Update system state tracking + let new_state = determine_system_state(report, self); + if new_state != self.current_state { + // Record time spent in previous state + let time_in_state = self.last_state_change.elapsed(); + *self + .state_durations + .entry(self.current_state.clone()) + .or_insert(Duration::ZERO) += time_in_state; + + // State changes (except to Idle) likely indicate user activity + if new_state != SystemState::Idle && new_state != SystemState::LowLoad { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on system state change to {new_state:?}"); + } + + // Update state + self.current_state = new_state; + self.last_state_change = Instant::now(); + } + + // Check for significant load changes + if report.system_load.load_avg_1min > 1.0 { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on system load"); + } + } + + /// Calculate CPU usage volatility (how much it's changing) + fn get_cpu_volatility(&self) -> f32 { + if self.cpu_usage_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.cpu_usage_history.len() { + sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs(); + } + + sum_of_changes / (self.cpu_usage_history.len() - 1) as f32 + } + + /// Calculate temperature volatility + fn get_temperature_volatility(&self) -> f32 { + if self.temperature_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.temperature_history.len() { + sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs(); + } + + sum_of_changes / (self.temperature_history.len() - 1) as f32 + } + + /// Determine if the system appears to be idle + fn is_system_idle(&self) -> bool { + if self.cpu_usage_history.is_empty() { + return false; + } + + // System considered idle if the average CPU usage of last readings is below 10% + let recent_avg = + self.cpu_usage_history.iter().sum::() / self.cpu_usage_history.len() as f32; + recent_avg < 10.0 && self.get_cpu_volatility() < 5.0 + } + + /// Calculate optimal polling interval based on system conditions + fn calculate_optimal_interval( + &self, + config: &AppConfig, + on_battery: bool, + ) -> anyhow::Result { + let params = IntervalParams { + base_interval: config.daemon.poll_interval_sec, + min_interval: config.daemon.min_poll_interval_sec, + max_interval: config.daemon.max_poll_interval_sec, + cpu_volatility: self.get_cpu_volatility(), + temp_volatility: self.get_temperature_volatility(), + battery_discharge_rate: self.battery_discharge_rate, + last_user_activity: self.last_user_activity.elapsed(), + is_system_idle: self.is_system_idle(), + on_battery, + }; + + compute_new(¶ms, self) + } +} + +/// Validates that poll interval configuration is consistent +/// Returns Ok if configuration is valid, Err with a descriptive message if invalid +fn validate_poll_intervals(min_interval: u64, max_interval: u64) -> anyhow::Result<()> { + if min_interval < 1 { + bail!("min_interval must be ≥ 1"); + } + if max_interval < 1 { + bail!("max_interval must be ≥ 1"); + } + if max_interval >= min_interval { + Ok(()) + } else { + bail!( + "Invalid interval configuration: max_interval ({max_interval}) is less than min_interval ({min_interval})" + ); + } +} + +/// Run the daemon +pub fn run_daemon(config: AppConfig) -> anyhow::Result<()> { + log::info!("Starting superfreq daemon..."); + + // Validate critical configuration values before proceeding + validate_poll_intervals( + config.daemon.min_poll_interval_sec, + config.daemon.max_poll_interval_sec, + )?; + + // Create a flag that will be set to true when a signal is received + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + + // Set up signal handlers + ctrlc::set_handler(move || { + log::info!("Received shutdown signal, exiting..."); + r.store(false, Ordering::SeqCst); + }) + .context("failed to set Ctrl-C handler")?; + + log::info!( + "Daemon initialized with poll interval: {}s", + config.daemon.poll_interval_sec + ); + + // Set up stats file if configured + if let Some(stats_path) = &config.daemon.stats_file_path { + log::info!("Stats will be written to: {stats_path}"); + } + + // Variables for adaptive polling + // Make sure that the poll interval is *never* zero to prevent a busy loop + let mut current_poll_interval = config.daemon.poll_interval_sec.max(1); + if config.daemon.poll_interval_sec == 0 { + log::warn!( + "Poll interval is set to zero in config, using 1s minimum to prevent a busy loop" + ); + } + let mut system_history = SystemHistory::default(); + + // Main loop + while running.load(Ordering::SeqCst) { + let start_time = Instant::now(); + + match monitor::collect_system_report(&config) { + Ok(report) => { + log::debug!("Collected system report, applying settings..."); + + // Store the current state before updating history + let previous_state = system_history.current_state.clone(); + + // Update system history with new data + system_history.update(&report); + + // Update the stats file if configured + if let Some(stats_path) = &config.daemon.stats_file_path { + if let Err(e) = write_stats_file(stats_path, &report) { + log::error!("Failed to write stats file: {e}"); + } + } + + match engine::determine_and_apply_settings(&report, &config, None) { + Ok(()) => { + log::debug!("Successfully applied system settings"); + + // If system state changed, log the new state + if system_history.current_state != previous_state { + log::info!( + "System state changed to: {:?}", + system_history.current_state + ); + } + } + Err(e) => { + log::error!("Error applying system settings: {e}"); + } + } + + // Check if we're on battery + let on_battery = !report.batteries.is_empty() + && report.batteries.first().is_some_and(|b| !b.ac_connected); + + // Calculate optimal polling interval if adaptive polling is enabled + if config.daemon.adaptive_interval { + match system_history.calculate_optimal_interval(&config, on_battery) { + Ok(optimal_interval) => { + // Store the new interval + system_history.last_computed_interval = Some(optimal_interval); + + log::debug!("Recalculated optimal interval: {optimal_interval}s"); + + // Don't change the interval too dramatically at once + match optimal_interval.cmp(¤t_poll_interval) { + std::cmp::Ordering::Greater => { + current_poll_interval = + (current_poll_interval + optimal_interval) / 2; + } + std::cmp::Ordering::Less => { + current_poll_interval = current_poll_interval + - ((current_poll_interval - optimal_interval) / 2).max(1); + } + std::cmp::Ordering::Equal => { + // No change needed when they're equal + } + } + } + Err(e) => { + // Log the error and stop the daemon when an invalid configuration is detected + log::error!("Critical configuration error: {e}"); + running.store(false, Ordering::SeqCst); + break; + } + } + + // Make sure that we respect the (user) configured min and max limits + current_poll_interval = current_poll_interval.clamp( + config.daemon.min_poll_interval_sec, + config.daemon.max_poll_interval_sec, + ); + + log::debug!("Adaptive polling: set interval to {current_poll_interval}s"); + } else { + // If adaptive polling is disabled, still apply battery-saving adjustment + if config.daemon.throttle_on_battery && on_battery { + let battery_multiplier = 2; // poll half as often on battery + + // We need to make sure `poll_interval_sec` is *at least* 1 + // before multiplying. + let safe_interval = config.daemon.poll_interval_sec.max(1); + current_poll_interval = (safe_interval * battery_multiplier) + .min(config.daemon.max_poll_interval_sec); + + log::debug!( + "On battery power, increased poll interval to {current_poll_interval}s" + ); + } else { + // Use the configured poll interval + current_poll_interval = config.daemon.poll_interval_sec.max(1); + if config.daemon.poll_interval_sec == 0 { + log::debug!( + "Using minimum poll interval of 1s instead of configured 0s" + ); + } + } + } + } + Err(e) => { + log::error!("Error collecting system report: {e}"); + } + } + + // Sleep for the remaining time in the poll interval + let elapsed = start_time.elapsed(); + let poll_duration = Duration::from_secs(current_poll_interval); + if elapsed < poll_duration { + let sleep_time = poll_duration - elapsed; + log::debug!("Sleeping for {}s until next cycle", sleep_time.as_secs()); + std::thread::sleep(sleep_time); + } + } + + log::info!("Daemon stopped"); + Ok(()) +} + +/// Write current system stats to a file for --stats to read +fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Error> { + let mut file = File::create(path)?; + + writeln!(file, "timestamp={:?}", report.timestamp)?; + + // CPU info + writeln!(file, "governor={:?}", report.cpu_global.current_governor)?; + writeln!(file, "turbo={:?}", report.cpu_global.turbo_status)?; + if let Some(temp) = report.cpu_global.average_temperature_celsius { + writeln!(file, "cpu_temp={temp:.1}")?; + } + + // Battery info + if !report.batteries.is_empty() { + let battery = &report.batteries[0]; + writeln!(file, "ac_power={}", battery.ac_connected)?; + if let Some(cap) = battery.capacity_percent { + writeln!(file, "battery_percent={cap}")?; + } + } + + // System load + writeln!(file, "load_1m={:.2}", report.system_load.load_avg_1min)?; + writeln!(file, "load_5m={:.2}", report.system_load.load_avg_5min)?; + writeln!(file, "load_15m={:.2}", report.system_load.load_avg_15min)?; + + Ok(()) +} + +/// Simplified system state used for determining when to adjust polling interval +#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)] +enum SystemState { + #[default] + Unknown, + OnAC, + OnBattery, + HighLoad, + LowLoad, + HighTemp, + Idle, +} + +/// Determine the current system state for adaptive polling +fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState { + // Check power state first + if !report.batteries.is_empty() { + if let Some(battery) = report.batteries.first() { + if battery.ac_connected { + return SystemState::OnAC; + } + return SystemState::OnBattery; + } + } + + // No batteries means desktop, so always AC + if report.batteries.is_empty() { + return SystemState::OnAC; + } + + // Check temperature + if let Some(temp) = report.cpu_global.average_temperature_celsius { + if temp > 80.0 { + return SystemState::HighTemp; + } + } + + // Check load first, as high load should take precedence over idle state + let avg_load = report.system_load.load_avg_1min; + if avg_load > 3.0 { + return SystemState::HighLoad; + } + + // Check idle state only if we don't have high load + if history.is_system_idle() { + return SystemState::Idle; + } + + // Check for low load + if avg_load < 0.5 { + return SystemState::LowLoad; + } + + // Default case + SystemState::Unknown +} diff --git a/src/main.rs b/src/main.rs index 52f33c9..0725e38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ mod config; // mod core; mod cpu; -// mod daemon; +mod daemon; // mod engine; // mod monitor; mod power_supply; @@ -56,11 +56,10 @@ fn real_main() -> anyhow::Result<()> { Command::Info => todo!(), Command::Start { config } => { - let _config = config::DaemonConfig::load_from(&config) + let config = config::DaemonConfig::load_from(&config) .context("failed to load daemon config file")?; - // daemon::run(config) - Ok(()) + daemon::run(config) } Command::CpuSet(delta) => delta.apply(),