diff --git a/config.toml b/config.toml deleted file mode 100644 index 2f796b7..0000000 --- a/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[[rule]] -priority = 0 diff --git a/src/config.rs b/src/config.rs index 9ca0498..585b7a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -255,7 +255,7 @@ pub enum Expression { pub struct Rule { priority: u8, - #[serde(default, rename = "if", skip_serializing_if = "is_default")] + #[serde(default, skip_serializing_if = "is_default")] if_: Expression, #[serde(default, skip_serializing_if = "is_default")] @@ -265,7 +265,7 @@ pub struct Rule { } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] -#[serde(default, rename_all = "kebab-case")] +#[serde(transparent, default, rename_all = "kebab-case")] pub struct DaemonConfig { #[serde(rename = "rule")] rules: Vec, diff --git a/src/cpu.rs b/src/cpu.rs index 0179746..17a5da1 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,9 +1,33 @@ use anyhow::{Context, bail}; use yansi::Paint as _; -use std::{fmt, string::ToString}; +use std::{fmt, fs, path::Path, string::ToString}; -use crate::fs; +fn exists(path: impl AsRef) -> bool { + let path = path.as_ref(); + + path.exists() +} + +// Not doing any anyhow stuff here as all the calls of this ignore errors. +fn read_u64(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref(); + + let content = fs::read_to_string(path)?; + + Ok(content.trim().parse()?) +} + +fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { + let path = path.as_ref(); + + fs::write(path, value).with_context(|| { + format!( + "failed to write '{value}' to '{path}'", + path = path.display(), + ) + }) +} #[derive(Debug, Clone, Copy)] pub struct Cpu { @@ -72,11 +96,11 @@ impl Cpu { pub fn rescan(&mut self) -> anyhow::Result<()> { let Self { number, .. } = self; - if !fs::exists(format!("/sys/devices/system/cpu/cpu{number}")) { + if !exists(format!("/sys/devices/system/cpu/cpu{number}")) { bail!("{self} does not exist"); } - let has_cpufreq = fs::exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); + let has_cpufreq = exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); self.has_cpufreq = has_cpufreq; @@ -86,7 +110,7 @@ impl Cpu { pub fn get_available_governors(&self) -> Vec { let Self { number, .. } = self; - let Some(Ok(content)) = fs::read(format!( + let Ok(content) = fs::read_to_string(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_available_governors" )) else { return Vec::new(); @@ -113,7 +137,7 @@ impl Cpu { ); } - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_governor"), governor, ) @@ -127,7 +151,7 @@ impl Cpu { pub fn get_available_epps(&self) -> Vec { let Self { number, .. } = self; - let Some(Ok(content)) = fs::read(format!( + let Ok(content) = fs::read_to_string(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_available_preferences" )) else { return Vec::new(); @@ -151,7 +175,7 @@ impl Cpu { ); } - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_preference"), epp, ) @@ -202,7 +226,7 @@ impl Cpu { ); } - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_bias"), epb, ) @@ -220,7 +244,7 @@ impl Cpu { let frequency_khz = frequency_mhz * 1000; let frequency_khz = frequency_khz.to_string(); - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq"), &frequency_khz, ) @@ -232,7 +256,7 @@ impl Cpu { fn validate_frequency_minimum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { let Self { number, .. } = self; - let Ok(minimum_frequency_khz) = fs::read_u64(format!( + let Ok(minimum_frequency_khz) = read_u64(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" )) else { // Just let it pass if we can't find anything. @@ -258,7 +282,7 @@ impl Cpu { let frequency_khz = frequency_mhz * 1000; let frequency_khz = frequency_khz.to_string(); - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_max_freq"), &frequency_khz, ) @@ -270,7 +294,7 @@ impl Cpu { fn validate_frequency_maximum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { let Self { number, .. } = self; - let Ok(maximum_frequency_khz) = fs::read_u64(format!( + let Ok(maximum_frequency_khz) = read_u64(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" )) else { // Just let it pass if we can't find anything. @@ -307,16 +331,16 @@ impl Cpu { let generic_boost_path = "/sys/devices/system/cpu/cpufreq/boost"; // Try each boost control path in order of specificity - if fs::write(intel_boost_path_negated, value_boost_negated).is_ok() { + if write(intel_boost_path_negated, value_boost_negated).is_ok() { return Ok(()); } - if fs::write(amd_boost_path, value_boost).is_ok() { + if write(amd_boost_path, value_boost).is_ok() { return Ok(()); } - if fs::write(msr_boost_path, value_boost).is_ok() { + if write(msr_boost_path, value_boost).is_ok() { return Ok(()); } - if fs::write(generic_boost_path, value_boost).is_ok() { + if write(generic_boost_path, value_boost).is_ok() { return Ok(()); } @@ -324,7 +348,7 @@ impl Cpu { if Self::all()?.iter().any(|cpu| { let Cpu { number, .. } = cpu; - fs::write( + write( format!("/sys/devices/system/cpu/cpu{number}/cpufreq/boost"), value_boost, ) diff --git a/src/daemon.rs b/src/daemon.rs index f2d2e3a..55241a5 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,15 +1,9 @@ use std::{ collections::VecDeque, ops, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, time::{Duration, Instant}, }; -use anyhow::Context; - use crate::config; /// Calculate the idle time multiplier based on system idle time. @@ -39,17 +33,13 @@ struct Daemon { /// Last time when there was user activity. last_user_activity: Instant, - /// The last computed polling interval. - last_polling_interval: Option, - - /// Whether if we are charging right now. - charging: bool, - /// CPU usage and temperature log. cpu_log: VecDeque, /// Power supply status log. power_supply_log: VecDeque, + + charging: bool, } struct CpuLog { @@ -177,7 +167,7 @@ impl Daemon { } impl Daemon { - fn polling_interval(&mut self) -> Duration { + fn polling_interval(&self) -> Duration { let mut interval = Duration::from_secs(5); // We are on battery, so we must be more conservative with our polling. @@ -195,8 +185,6 @@ impl Daemon { } } - // If we can't deterine the discharge rate, that means that - // we were very recently started. Which is user activity. None => { interval *= 2; } @@ -225,38 +213,10 @@ impl Daemon { } } - let interval = match self.last_polling_interval { - Some(last_interval) => Duration::from_secs_f64( - // 30% of current computed interval, 70% of last interval. - interval.as_secs_f64() * 0.3 + last_interval.as_secs_f64() * 0.7, - ), - - None => interval, - }; - - let interval = Duration::from_secs_f64(interval.as_secs_f64().clamp(1.0, 30.0)); - - self.last_polling_interval = Some(interval); - - interval + todo!("implement rest from daemon_old.rs") } } pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { - log::info!("starting daemon..."); - - let cancelled = Arc::new(AtomicBool::new(false)); - - let cancelled_ = Arc::clone(&cancelled); - ctrlc::set_handler(move || { - log::info!("received shutdown signal"); - cancelled_.store(true, Ordering::SeqCst); - }) - .context("failed to set Ctrl-C handler")?; - - while !cancelled.load(Ordering::SeqCst) {} - - log::info!("exiting..."); - Ok(()) } diff --git a/src/daemon_old.rs b/src/daemon_old.rs index 3a20cb4..ba6d37d 100644 --- a/src/daemon_old.rs +++ b/src/daemon_old.rs @@ -12,6 +12,143 @@ 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 { @@ -37,6 +174,23 @@ struct SystemHistory { 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) { @@ -200,6 +354,45 @@ impl SystemHistory { 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 @@ -368,6 +561,36 @@ pub fn run_daemon(config: AppConfig) -> 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 { diff --git a/src/fs.rs b/src/fs.rs deleted file mode 100644 index b1d1c71..0000000 --- a/src/fs.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::{fs, io, path::Path}; - -use anyhow::Context; - -pub fn exists(path: impl AsRef) -> bool { - let path = path.as_ref(); - - path.exists() -} - -pub fn read_dir(path: impl AsRef) -> anyhow::Result { - let path = path.as_ref(); - - fs::read_dir(path) - .with_context(|| format!("failed to read directory '{path}'", path = path.display())) -} - -pub fn read(path: impl AsRef) -> Option> { - let path = path.as_ref(); - - match fs::read_to_string(path) { - Ok(string) => Some(Ok(string)), - - Err(error) if error.kind() == io::ErrorKind::NotFound => None, - - Err(error) => Some( - Err(error).with_context(|| format!("failed to read '{path}", path = path.display())), - ), - } -} - -pub fn read_u64(path: impl AsRef) -> anyhow::Result { - let path = path.as_ref(); - - let content = fs::read_to_string(path) - .with_context(|| format!("failed to read '{path}'", path = path.display()))?; - - Ok(content.trim().parse().with_context(|| { - format!( - "failed to parse contents of '{path}' as a unsigned number", - path = path.display(), - ) - })?) -} - -pub fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { - let path = path.as_ref(); - - fs::write(path, value).with_context(|| { - format!( - "failed to write '{value}' to '{path}'", - path = path.display(), - ) - }) -} diff --git a/src/main.rs b/src/main.rs index 825465d..0725e38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,10 @@ -mod cpu; -mod power_supply; -mod system; - -mod fs; - mod config; // mod core; +mod cpu; mod daemon; // mod engine; // mod monitor; +mod power_supply; use anyhow::Context; use clap::Parser as _; diff --git a/src/power_supply.rs b/src/power_supply.rs index f1dcb41..1f69a3c 100644 --- a/src/power_supply.rs +++ b/src/power_supply.rs @@ -1,12 +1,22 @@ -use anyhow::{Context, anyhow, bail}; +use anyhow::{Context, bail}; use yansi::Paint as _; use std::{ - fmt, + fmt, fs, path::{Path, PathBuf}, }; -use crate::fs; +// TODO: Migrate to central utils file. Same exists in cpu.rs. +fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { + let path = path.as_ref(); + + fs::write(path, value).with_context(|| { + format!( + "failed to write '{value}' to '{path}'", + path = path.display(), + ) + }) +} /// Represents a pattern of path suffixes used to control charge thresholds /// for different device vendors. @@ -126,8 +136,7 @@ impl PowerSupply { fn get_type(&self) -> anyhow::Result { let type_path = self.path.join("type"); - let type_ = fs::read(&type_path) - .with_context(|| format!("'{path}' doesn't exist", path = type_path.display()))? + let type_ = fs::read_to_string(&type_path) .with_context(|| format!("failed to read '{path}'", path = type_path.display()))?; Ok(type_) @@ -171,9 +180,9 @@ impl PowerSupply { } pub fn set_charge_threshold_start(&self, charge_threshold_start: u8) -> anyhow::Result<()> { - fs::write( + write( &self.charge_threshold_path_start().ok_or_else(|| { - anyhow!( + anyhow::anyhow!( "power supply '{name}' does not support changing charge threshold levels", name = self.name, ) @@ -188,9 +197,9 @@ impl PowerSupply { } pub fn set_charge_threshold_end(&self, charge_threshold_end: u8) -> anyhow::Result<()> { - fs::write( + write( &self.charge_threshold_path_end().ok_or_else(|| { - anyhow!( + anyhow::anyhow!( "power supply '{name}' does not support changing charge threshold levels", name = self.name, ) @@ -207,7 +216,7 @@ impl PowerSupply { pub fn get_available_platform_profiles() -> Vec { let path = "/sys/firmware/acpi/platform_profile_choices"; - let Some(Ok(content)) = fs::read(path) else { + let Ok(content) = fs::read_to_string(path) else { return Vec::new(); }; @@ -236,7 +245,7 @@ impl PowerSupply { ); } - fs::write("/sys/firmware/acpi/platform_profile", profile) + write("/sys/firmware/acpi/platform_profile", profile) .context("this probably means that your system does not support changing ACPI profiles") } } diff --git a/src/system.rs b/src/system.rs deleted file mode 100644 index 1d3e697..0000000 --- a/src/system.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub struct System { - pub is_desktop: bool, -} - -impl System { - pub fn new() -> anyhow::Result { - let mut system = Self { is_desktop: false }; - system.rescan()?; - - Ok(system) - } - - pub fn rescan(&mut self) -> anyhow::Result<()> {} -}