diff --git a/Cargo.lock b/Cargo.lock index 59edb23..4ac1b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.38" @@ -110,6 +116,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "ctrlc" +version = "3.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" +dependencies = [ + "nix", + "windows-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -204,6 +220,18 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -295,6 +323,7 @@ name = "superfreq" version = "0.1.0" dependencies = [ "clap", + "ctrlc", "dirs", "num_cpus", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8e458e6..97f5926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ toml = "0.8" dirs = "6.0" clap = { version = "4.0", features = ["derive"] } num_cpus = "1.16" +ctrlc = "3.4" diff --git a/src/config.rs b/src/config.rs index 6a9d4fd..9cc1eac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,30 +1,32 @@ +use crate::core::TurboSetting; use serde::Deserialize; -use std::path::{Path, PathBuf}; use std::fs; -use crate::core::{OperationalMode, TurboSetting}; +use std::path::PathBuf; // Structs for configuration using serde::Deserialize #[derive(Deserialize, 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 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, + pub turbo_auto_settings: Option, } impl Default for ProfileConfig { fn default() -> Self { - ProfileConfig { + Self { governor: Some("schedutil".to_string()), // common sensible default (?) turbo: Some(TurboSetting::Auto), - 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 + 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: Some(TurboAutoSettings::default()), } } } @@ -39,9 +41,11 @@ pub struct AppConfig { pub ignored_power_supplies: Option>, #[serde(default = "default_poll_interval_sec")] pub poll_interval_sec: u64, + #[serde(default)] + pub daemon: DaemonConfig, } -fn default_poll_interval_sec() -> u64 { +const fn default_poll_interval_sec() -> u64 { 5 } @@ -55,24 +59,24 @@ pub enum ConfigError { } impl From for ConfigError { - fn from(err: std::io::Error) -> ConfigError { - ConfigError::Io(err) + fn from(err: std::io::Error) -> Self { + Self::Io(err) } } impl From for ConfigError { - fn from(err: toml::de::Error) -> ConfigError { - ConfigError::Toml(err) + fn from(err: toml::de::Error) -> Self { + Self::Toml(err) } } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ConfigError::Io(e) => write!(f, "I/O error: {}", e), - ConfigError::Toml(e) => write!(f, "TOML parsing error: {}", e), - ConfigError::NoValidConfigFound => write!(f, "No valid configuration file found."), - ConfigError::HomeDirNotFound => write!(f, "Could not determine user home directory."), + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Toml(e) => write!(f, "TOML parsing error: {e}"), + Self::NoValidConfigFound => write!(f, "No valid configuration file found."), + Self::HomeDirNotFound => write!(f, "Could not determine user home directory."), } } } @@ -90,7 +94,9 @@ pub fn load_config() -> Result { let user_config_path = home_dir.join(".config/auto_cpufreq_rs/config.toml"); config_paths.push(user_config_path); } else { - eprintln!("Warning: Could not determine home directory. User-specific config will not be loaded."); + eprintln!( + "Warning: Could not determine home directory. User-specific config will not be loaded." + ); } // System-wide path @@ -108,9 +114,23 @@ pub fn load_config() -> Result { let app_config = AppConfig { charger: ProfileConfig::from(toml_app_config.charger), battery: ProfileConfig::from(toml_app_config.battery), - battery_charge_thresholds: toml_app_config.battery_charge_thresholds, + battery_charge_thresholds: toml_app_config + .battery_charge_thresholds, ignored_power_supplies: toml_app_config.ignored_power_supplies, poll_interval_sec: toml_app_config.poll_interval_sec, + 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, + }, }; return Ok(app_config); } @@ -135,6 +155,7 @@ pub fn load_config() -> Result { battery_charge_thresholds: default_toml_config.battery_charge_thresholds, ignored_power_supplies: default_toml_config.ignored_power_supplies, poll_interval_sec: default_toml_config.poll_interval_sec, + daemon: DaemonConfig::default(), }) } @@ -160,11 +181,13 @@ pub struct AppConfigToml { pub ignored_power_supplies: Option>, #[serde(default = "default_poll_interval_sec")] pub poll_interval_sec: u64, + #[serde(default)] + pub daemon: DaemonConfigToml, } impl Default for ProfileConfigToml { fn default() -> Self { - ProfileConfigToml { + Self { governor: Some("schedutil".to_string()), turbo: Some("auto".to_string()), epp: None, @@ -176,22 +199,155 @@ impl Default for ProfileConfigToml { } } +#[derive(Deserialize, 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, +} + +// 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 + +const fn default_load_threshold_high() -> f32 { + DEFAULT_LOAD_THRESHOLD_HIGH +} +const fn default_load_threshold_low() -> f32 { + DEFAULT_LOAD_THRESHOLD_LOW +} +const fn default_temp_threshold_high() -> f32 { + DEFAULT_TEMP_THRESHOLD_HIGH +} + +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, + } + } +} impl From for ProfileConfig { fn from(toml_config: ProfileConfigToml) -> Self { - ProfileConfig { + Self { governor: toml_config.governor, - turbo: toml_config.turbo.and_then(|s| match s.to_lowercase().as_str() { - "always" => Some(TurboSetting::Always), - "auto" => Some(TurboSetting::Auto), - "never" => Some(TurboSetting::Never), - _ => None, - }), + turbo: toml_config + .turbo + .and_then(|s| match s.to_lowercase().as_str() { + "always" => Some(TurboSetting::Always), + "auto" => Some(TurboSetting::Auto), + "never" => Some(TurboSetting::Never), + _ => None, + }), 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: Some(TurboAutoSettings::default()), + } + } +} + +#[derive(Deserialize, 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, 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(), + } + } +} + +const fn default_adaptive_interval() -> bool { + false +} + +const fn default_min_poll_interval_sec() -> u64 { + 1 +} + +const fn default_max_poll_interval_sec() -> u64 { + 30 +} + +const fn default_throttle_on_battery() -> bool { + true +} + +const fn default_log_level() -> LogLevel { + LogLevel::Info +} + +const fn default_stats_file_path() -> Option { + None +} + +#[derive(Deserialize, 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/conflict.rs b/src/conflict.rs new file mode 100644 index 0000000..fe08e70 --- /dev/null +++ b/src/conflict.rs @@ -0,0 +1,128 @@ +use std::path::Path; +use std::process::Command; + +/// Represents detected conflicts with other power management services +#[derive(Debug)] +pub struct ConflictDetection { + /// Whether TLP service was detected + pub tlp: bool, + /// Whether GNOME Power Profiles daemon was detected + pub gnome_power: bool, + /// Whether tuned service was detected + pub tuned: bool, + /// Other power managers that were detected + pub other: Vec, +} + +impl ConflictDetection { + /// Returns true if any conflicts were detected + pub fn has_conflicts(&self) -> bool { + self.tlp || self.gnome_power || self.tuned || !self.other.is_empty() + } + + /// Get formatted conflict information + pub fn get_conflict_message(&self) -> String { + if !self.has_conflicts() { + return "No conflicts detected with other power management services.".to_string(); + } + + let mut message = + "Potential conflicts detected with other power management services:\n".to_string(); + + if self.tlp { + message.push_str("- TLP service is active. This may interfere with CPU settings.\n"); + } + + if self.gnome_power { + message.push_str( + "- GNOME Power Profiles daemon is active. This may override CPU/power settings.\n", + ); + } + + if self.tuned { + message.push_str( + "- Tuned service is active. This may conflict with CPU frequency settings.\n", + ); + } + + for other in &self.other { + message.push_str(&format!( + "- {other} is active. This may conflict with superfreq.\n" + )); + } + + message.push_str("\nConsider disabling conflicting services for optimal operation."); + + message + } +} + +/// Detect if systemctl is available +fn systemctl_exists() -> bool { + Command::new("sh") + .arg("-c") + .arg("command -v systemctl") + .status() + .is_ok_and(|status| status.success()) +} + +/// Check if a specific systemd service is active. +// TODO: maybe we can use some kind of a binding here +// or figure out a better detection method? +fn is_service_active(service: &str) -> bool { + if !systemctl_exists() { + return false; + } + + Command::new("systemctl") + .arg("--quiet") + .arg("is-active") + .arg(service) + .status() + .is_ok_and(|status| status.success()) +} + +/// Check for conflicts with other power management services +pub fn detect_conflicts() -> ConflictDetection { + let mut conflicts = ConflictDetection { + tlp: false, + gnome_power: false, + tuned: false, + other: Vec::new(), + }; + + // Check for TLP + conflicts.tlp = is_service_active("tlp.service"); + + // Check for GNOME Power Profiles daemon + conflicts.gnome_power = is_service_active("power-profiles-daemon.service"); + + // Check for tuned + conflicts.tuned = is_service_active("tuned.service"); + + // Check for other common power managers + let other_services = ["thermald.service", "powertop.service"]; + for service in other_services { + if is_service_active(service) { + conflicts.other.push(service.to_string()); + } + } + + // Also check if TLP is installed but not running as a service + // FIXME: This will obviously not work on non-FHS distros like NixOS + // which I kinda want to prioritize. Though, since we can't actually + // predict store paths I also don't know how else we can perform this + // check... + if !conflicts.tlp + && Path::new("/usr/share/tlp").exists() + && Command::new("sh") + .arg("-c") + .arg("tlp-stat -s 2>/dev/null | grep -q 'TLP power save = enabled'") + .status() + .is_ok_and(|status| status.success()) + { + conflicts.tlp = true; + } + + conflicts +} diff --git a/src/core.rs b/src/core.rs index fa3f188..2be64ff 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,3 +1,31 @@ +use clap::ValueEnum; +use serde::Deserialize; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] +pub enum TurboSetting { + Always, // turbo is forced on (if possible) + Auto, // system or driver controls turbo + Never, // turbo is forced off +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum GovernorOverrideMode { + Performance, + Powersave, + Reset, +} + +impl fmt::Display for GovernorOverrideMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Performance => write!(f, "performance"), + Self::Powersave => write!(f, "powersave"), + Self::Reset => write!(f, "reset"), + } + } +} + pub struct SystemInfo { // Overall system details pub cpu_model: String, @@ -59,13 +87,3 @@ pub enum OperationalMode { Powersave, Performance, } - -use clap::ValueEnum; -use serde::Deserialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] -pub enum TurboSetting { - Always, // turbo is forced on (if possible) - Auto, // system or driver controls turbo - Never, // turbo is forced off -} diff --git a/src/cpu.rs b/src/cpu.rs index 52ea6b8..ae45ffb 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,4 +1,4 @@ -use crate::core::TurboSetting; +use crate::core::{GovernorOverrideMode, TurboSetting}; use core::str; use std::{fs, io, path::Path, string::ToString}; @@ -276,3 +276,78 @@ pub fn get_platform_profiles() -> Result> { .map(ToString::to_string) .collect()) } + +/// Path for storing the governor override state +const GOVERNOR_OVERRIDE_PATH: &str = "/etc/superfreq/governor_override"; + +/// Force a specific CPU governor or reset to automatic mode +pub fn force_governor(mode: GovernorOverrideMode) -> Result<()> { + // Create directory if it doesn't exist + let dir_path = Path::new("/etc/superfreq"); + if !dir_path.exists() { + fs::create_dir_all(dir_path).map_err(|e| { + if e.kind() == io::ErrorKind::PermissionDenied { + ControlError::PermissionDenied(format!( + "Permission denied creating directory: {}. Try running with sudo.", + dir_path.display() + )) + } else { + ControlError::Io(e) + } + })?; + } + + match mode { + GovernorOverrideMode::Reset => { + // Remove the override file if it exists + if Path::new(GOVERNOR_OVERRIDE_PATH).exists() { + fs::remove_file(GOVERNOR_OVERRIDE_PATH).map_err(|e| { + if e.kind() == io::ErrorKind::PermissionDenied { + ControlError::PermissionDenied(format!( + "Permission denied removing override file: {GOVERNOR_OVERRIDE_PATH}. Try running with sudo." + )) + } else { + ControlError::Io(e) + } + })?; + println!( + "Governor override has been reset. Normal profile-based settings will be used." + ); + } else { + println!("No governor override was set."); + } + Ok(()) + } + GovernorOverrideMode::Performance | GovernorOverrideMode::Powersave => { + // Create the override file with the selected governor + let governor = mode.to_string().to_lowercase(); + fs::write(GOVERNOR_OVERRIDE_PATH, &governor).map_err(|e| { + if e.kind() == io::ErrorKind::PermissionDenied { + ControlError::PermissionDenied(format!( + "Permission denied writing to override file: {GOVERNOR_OVERRIDE_PATH}. Try running with sudo." + )) + } else { + ControlError::Io(e) + } + })?; + + // Also apply the governor immediately + set_governor(&governor, None)?; + + println!( + "Governor override set to '{governor}'. This setting will persist across reboots." + ); + println!("To reset, use: superfreq force-governor reset"); + Ok(()) + } + } +} + +/// Get the current governor override if set +pub fn get_governor_override() -> Option { + if Path::new(GOVERNOR_OVERRIDE_PATH).exists() { + fs::read_to_string(GOVERNOR_OVERRIDE_PATH).ok() + } else { + None + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..bcf00ee --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,303 @@ +use crate::config::{AppConfig, LogLevel}; +use crate::conflict; +use crate::core::SystemReport; +use crate::engine; +use crate::monitor; +use std::fs::File; +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Run the daemon +pub fn run_daemon(config: AppConfig, verbose: bool) -> Result<(), Box> { + // Set effective log level based on config and verbose flag + let effective_log_level = if verbose { + LogLevel::Debug + } else { + config.daemon.log_level + }; + + log_message( + &effective_log_level, + LogLevel::Info, + "Starting superfreq daemon...", + ); + + // Check for conflicts with other power management services + let conflicts = conflict::detect_conflicts(); + if conflicts.has_conflicts() { + log_message( + &effective_log_level, + LogLevel::Warning, + &conflicts.get_conflict_message(), + ); + } + + // 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 || { + println!("Received shutdown signal, exiting..."); + r.store(false, Ordering::SeqCst); + }) + .expect("Error setting Ctrl-C handler"); + + log_message( + &effective_log_level, + LogLevel::Info, + &format!( + "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_message( + &effective_log_level, + LogLevel::Info, + &format!("Stats will be written to: {stats_path}"), + ); + } + + // Variables for adaptive polling + let mut current_poll_interval = config.daemon.poll_interval_sec; + let mut last_settings_change = Instant::now(); + let mut last_system_state = SystemState::Unknown; + + // Main loop + while running.load(Ordering::SeqCst) { + let start_time = Instant::now(); + + match monitor::collect_system_report(&config) { + Ok(report) => { + log_message( + &effective_log_level, + LogLevel::Debug, + "Collected system report, applying settings...", + ); + + // Determine current system state + let current_state = determine_system_state(&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_message( + &effective_log_level, + LogLevel::Error, + &format!("Failed to write stats file: {e}"), + ); + } + } + + match engine::determine_and_apply_settings(&report, &config, None) { + Ok(()) => { + log_message( + &effective_log_level, + LogLevel::Debug, + "Successfully applied system settings", + ); + + // If system state changed or settings were applied differently, record the time + if current_state != last_system_state { + last_settings_change = Instant::now(); + last_system_state = current_state.clone(); + + log_message( + &effective_log_level, + LogLevel::Info, + &format!("System state changed to: {current_state:?}"), + ); + } + } + Err(e) => { + log_message( + &effective_log_level, + LogLevel::Error, + &format!("Error applying system settings: {e}"), + ); + } + } + + // Adjust poll interval if adaptive polling is enabled + if config.daemon.adaptive_interval { + let time_since_change = last_settings_change.elapsed().as_secs(); + + // If we've been stable for a while, increase the interval (up to max) + if time_since_change > 60 { + current_poll_interval = + (current_poll_interval * 2).min(config.daemon.max_poll_interval_sec); + + log_message( + &effective_log_level, + LogLevel::Debug, + &format!( + "Adaptive polling: increasing interval to {current_poll_interval}s" + ), + ); + } else if time_since_change < 10 { + // If we've had recent changes, decrease the interval (down to min) + current_poll_interval = + (current_poll_interval / 2).max(config.daemon.min_poll_interval_sec); + + log_message( + &effective_log_level, + LogLevel::Debug, + &format!( + "Adaptive polling: decreasing interval to {current_poll_interval}s" + ), + ); + } + } else { + // If not adaptive, use the configured poll interval + current_poll_interval = config.daemon.poll_interval_sec; + } + + // If on battery and throttling is enabled, lengthen the poll interval to save power + if config.daemon.throttle_on_battery + && !report.batteries.is_empty() + && report.batteries.first().is_some_and(|b| !b.ac_connected) + { + let battery_multiplier = 2; // Poll half as often on battery + current_poll_interval = (current_poll_interval * battery_multiplier) + .min(config.daemon.max_poll_interval_sec); + + log_message( + &effective_log_level, + LogLevel::Debug, + "On battery power, increasing poll interval to save energy", + ); + } + } + Err(e) => { + log_message( + &effective_log_level, + LogLevel::Error, + &format!("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_message( + &effective_log_level, + LogLevel::Debug, + &format!("Sleeping for {}s until next cycle", sleep_time.as_secs()), + ); + std::thread::sleep(sleep_time); + } + } + + log_message(&effective_log_level, LogLevel::Info, "Daemon stopped"); + Ok(()) +} + +/// Log a message based on the current log level +fn log_message(effective_level: &LogLevel, msg_level: LogLevel, message: &str) { + // Only log messages at or above the effective log level + let should_log = match effective_level { + LogLevel::Error => matches!(msg_level, LogLevel::Error), + LogLevel::Warning => matches!(msg_level, LogLevel::Error | LogLevel::Warning), + LogLevel::Info => matches!( + msg_level, + LogLevel::Error | LogLevel::Warning | LogLevel::Info + ), + LogLevel::Debug => true, + }; + + if should_log { + match msg_level { + LogLevel::Error => eprintln!("ERROR: {message}"), + LogLevel::Warning => eprintln!("WARNING: {message}"), + LogLevel::Info => println!("INFO: {message}"), + LogLevel::Debug => println!("DEBUG: {message}"), + } + } +} + +/// 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)] +enum SystemState { + Unknown, + OnAC, + OnBattery, + HighLoad, + LowLoad, + HighTemp, +} + +/// Determine the current system state for adaptive polling +fn determine_system_state(report: &SystemReport) -> 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 + let avg_load = report.system_load.load_avg_1min; + if avg_load > 3.0 { + return SystemState::HighLoad; + } + if avg_load < 0.5 { + return SystemState::LowLoad; + } + + // Default case + SystemState::Unknown +} diff --git a/src/engine.rs b/src/engine.rs index d322aaa..a047196 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,5 +1,5 @@ -use crate::core::{SystemReport, OperationalMode, TurboSetting}; use crate::config::{AppConfig, ProfileConfig}; +use crate::core::{OperationalMode, SystemReport, TurboSetting}; use crate::cpu::{self, ControlError}; #[derive(Debug)] @@ -10,15 +10,15 @@ pub enum EngineError { impl From for EngineError { fn from(err: ControlError) -> Self { - EngineError::ControlError(err) + Self::ControlError(err) } } impl std::fmt::Display for EngineError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - EngineError::ControlError(e) => write!(f, "CPU control error: {}", e), - EngineError::ConfigurationError(s) => write!(f, "Configuration error: {}", s), + Self::ControlError(e) => write!(f, "CPU control error: {e}"), + Self::ConfigurationError(s) => write!(f, "Configuration error: {s}"), } } } @@ -26,8 +26,8 @@ impl std::fmt::Display for EngineError { impl std::error::Error for EngineError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - EngineError::ControlError(e) => Some(e), - EngineError::ConfigurationError(_) => None, + Self::ControlError(e) => Some(e), + Self::ConfigurationError(_) => None, } } } @@ -39,6 +39,15 @@ pub fn determine_and_apply_settings( config: &AppConfig, force_mode: Option, ) -> Result<(), EngineError> { + // First, check if there's a governor override set + if let Some(override_governor) = cpu::get_governor_override() { + println!( + "Engine: Governor override is active: '{}'. Setting governor.", + override_governor.trim() + ); + cpu::set_governor(override_governor.trim(), None)?; + } + let selected_profile_config: &ProfileConfig; if let Some(mode) = force_mode { @@ -57,8 +66,8 @@ pub fn determine_and_apply_settings( // If no batteries, assume AC power (desktop). // Otherwise, check the ac_connected status from the (first) battery. // XXX: This relies on the setting ac_connected in BatteryInfo being set correctly. - let on_ac_power = report.batteries.is_empty() || - report.batteries.first().map_or(false, |b| b.ac_connected); + let on_ac_power = report.batteries.is_empty() + || report.batteries.first().is_some_and(|b| b.ac_connected); if on_ac_power { println!("Engine: On AC power, selecting Charger profile."); @@ -74,41 +83,129 @@ pub fn determine_and_apply_settings( // and we'd like to replace them with proper logging in the future. if let Some(governor) = &selected_profile_config.governor { - println!("Engine: Setting governor to '{}'", governor); + println!("Engine: Setting governor to '{governor}'"); cpu::set_governor(governor, None)?; } if let Some(turbo_setting) = selected_profile_config.turbo { - println!("Engine: Setting turbo to '{:?}'", turbo_setting); - cpu::set_turbo(turbo_setting)?; + println!("Engine: Setting turbo to '{turbo_setting:?}'"); + match turbo_setting { + TurboSetting::Auto => { + println!("Engine: Managing turbo in auto mode based on system conditions"); + manage_auto_turbo(report, selected_profile_config)?; + } + _ => cpu::set_turbo(turbo_setting)?, + } } if let Some(epp) = &selected_profile_config.epp { - println!("Engine: Setting EPP to '{}'", epp); + println!("Engine: Setting EPP to '{epp}'"); cpu::set_epp(epp, None)?; } if let Some(epb) = &selected_profile_config.epb { - println!("Engine: Setting EPB to '{}'", epb); + println!("Engine: Setting EPB to '{epb}'"); cpu::set_epb(epb, None)?; } if let Some(min_freq) = selected_profile_config.min_freq_mhz { - println!("Engine: Setting min frequency to '{} MHz'", min_freq); + println!("Engine: Setting min frequency to '{min_freq} MHz'"); cpu::set_min_frequency(min_freq, None)?; } if let Some(max_freq) = selected_profile_config.max_freq_mhz { - println!("Engine: Setting max frequency to '{} MHz'", max_freq); + println!("Engine: Setting max frequency to '{max_freq} MHz'"); cpu::set_max_frequency(max_freq, None)?; } if let Some(profile) = &selected_profile_config.platform_profile { - println!("Engine: Setting platform profile to '{}'", profile); + println!("Engine: Setting platform profile to '{profile}'"); cpu::set_platform_profile(profile)?; } println!("Engine: Profile settings applied successfully."); Ok(()) -} \ No newline at end of file +} + +fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<(), EngineError> { + // Get the auto turbo settings from the config, or use defaults + let turbo_settings = config.turbo_auto_settings.clone().unwrap_or_default(); + + // Get average CPU temperature and CPU load + let cpu_temp = report.cpu_global.average_temperature_celsius; + + // Check if we have CPU usage data available + let avg_cpu_usage = if report.cpu_cores.is_empty() { + None + } else { + let sum: f32 = report + .cpu_cores + .iter() + .filter_map(|core| core.usage_percent) + .sum(); + let count = report + .cpu_cores + .iter() + .filter(|core| core.usage_percent.is_some()) + .count(); + + if count > 0 { + Some(sum / count as f32) + } else { + None + } + }; + + // Decision logic for enabling/disabling turbo + let enable_turbo = match (cpu_temp, avg_cpu_usage) { + // If temperature is too high, disable turbo regardless of load + (Some(temp), _) if temp >= turbo_settings.temp_threshold_high => { + println!( + "Engine: Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)", + temp, turbo_settings.temp_threshold_high + ); + false + } + // If load is high enough, enable turbo (unless temp already caused it to disable) + (_, Some(usage)) if usage >= turbo_settings.load_threshold_high => { + println!( + "Engine: Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)", + usage, turbo_settings.load_threshold_high + ); + true + } + // If load is low, disable turbo + (_, Some(usage)) if usage <= turbo_settings.load_threshold_low => { + println!( + "Engine: Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)", + usage, turbo_settings.load_threshold_low + ); + false + } + // In intermediate load scenarios or if we can't determine, leave turbo in current state + // For now, we'll disable it as a safe default + _ => { + println!("Engine: Auto Turbo: Disabled (default for indeterminate state)"); + false + } + }; + + // Apply the turbo setting + let turbo_setting = if enable_turbo { + TurboSetting::Always + } else { + TurboSetting::Never + }; + + match cpu::set_turbo(turbo_setting) { + Ok(()) => { + println!( + "Engine: Auto Turbo: Successfully set turbo to {}", + if enable_turbo { "enabled" } else { "disabled" } + ); + Ok(()) + } + Err(e) => Err(EngineError::ControlError(e)), + } +} diff --git a/src/main.rs b/src/main.rs index 3a668a5..f114a3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ mod config; +mod conflict; mod core; mod cpu; +mod daemon; mod engine; mod monitor; use crate::config::AppConfig; -use crate::core::TurboSetting; +use crate::core::{GovernorOverrideMode, TurboSetting}; use clap::Parser; #[derive(Parser, Debug)] @@ -19,12 +21,23 @@ struct Cli { enum Commands { /// Display current system information Info, + /// Run as a daemon in the background + Daemon { + #[clap(long)] + verbose: bool, + }, /// Set CPU governor SetGovernor { governor: String, #[clap(long)] core_id: Option, }, + /// Force a specific governor mode persistently + ForceGovernor { + /// Mode to force: performance, powersave, or reset + #[clap(value_enum)] + mode: GovernorOverrideMode, + }, /// Set turbo boost behavior SetTurbo { #[clap(value_enum)] @@ -66,7 +79,7 @@ fn main() { let config = match config::load_config() { Ok(cfg) => cfg, Err(e) => { - eprintln!("Error loading configuration: {}. Using default values.", e); + eprintln!("Error loading configuration: {e}. Using default values."); // Proceed with default config if loading fails, as per previous steps AppConfig::default() } @@ -98,7 +111,7 @@ fn main() { "Average CPU Temperature: {}", report.cpu_global.average_temperature_celsius.map_or_else( || "N/A (CPU temperature sensor not detected)".to_string(), - |t| format!("{:.1}°C", t) + |t| format!("{t:.1}°C") ) ); @@ -118,10 +131,10 @@ fn main() { .map_or_else(|| "N/A".to_string(), |f| f.to_string()), core_info .usage_percent - .map_or_else(|| "N/A".to_string(), |f| format!("{:.1}", f)), + .map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}")), core_info .temperature_celsius - .map_or_else(|| "N/A".to_string(), |f| format!("{:.1}", f)) + .map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}")) ); } @@ -140,7 +153,7 @@ fn main() { .map_or_else(|| "N/A".to_string(), |c| c.to_string()), battery_info .power_rate_watts - .map_or_else(|| "N/A".to_string(), |p| format!("{:.2}", p)), + .map_or_else(|| "N/A".to_string(), |p| format!("{p:.2}")), battery_info .charge_start_threshold .map_or_else(|| "N/A".to_string(), |t| t.to_string()), @@ -164,6 +177,9 @@ fn main() { }, Some(Commands::SetGovernor { governor, core_id }) => cpu::set_governor(&governor, core_id) .map_err(|e| Box::new(e) as Box), + Some(Commands::ForceGovernor { mode }) => { + cpu::force_governor(mode).map_err(|e| Box::new(e) as Box) + } Some(Commands::SetTurbo { setting }) => { cpu::set_turbo(setting).map_err(|e| Box::new(e) as Box) } @@ -183,17 +199,18 @@ fn main() { } Some(Commands::SetPlatformProfile { profile }) => cpu::set_platform_profile(&profile) .map_err(|e| Box::new(e) as Box), + Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), None => { println!("Welcome to superfreq! Use --help for commands."); - println!("Current effective configuration: {:?}", config); + println!("Current effective configuration: {config:?}"); Ok(()) } }; if let Err(e) = command_result { - eprintln!("Error executing command: {}", e); + eprintln!("Error executing command: {e}"); if let Some(source) = e.source() { - eprintln!("Caused by: {}", source); + eprintln!("Caused by: {source}"); } // TODO: Consider specific error handling for PermissionDenied from cpu here // For example, check if e.downcast_ref::() matches PermissionDenied diff --git a/src/monitor.rs b/src/monitor.rs index 96e3aa3..a7918b3 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -20,21 +20,21 @@ pub enum SysMonitorError { } impl From for SysMonitorError { - fn from(err: io::Error) -> SysMonitorError { - SysMonitorError::Io(err) + fn from(err: io::Error) -> Self { + Self::Io(err) } } impl std::fmt::Display for SysMonitorError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - SysMonitorError::Io(e) => write!(f, "I/O error: {}", e), - SysMonitorError::ReadError(s) => write!(f, "Failed to read sysfs path: {}", s), - SysMonitorError::ParseError(s) => write!(f, "Failed to parse value: {}", s), - SysMonitorError::ProcStatParseError(s) => { - write!(f, "Failed to parse /proc/stat: {}", s) + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::ReadError(s) => write!(f, "Failed to read sysfs path: {s}"), + Self::ParseError(s) => write!(f, "Failed to parse value: {s}"), + Self::ProcStatParseError(s) => { + write!(f, "Failed to parse /proc/stat: {s}") } - SysMonitorError::NotAvailable(s) => write!(f, "Information not available: {}", s), + Self::NotAvailable(s) => write!(f, "Information not available: {s}"), } } } @@ -123,7 +123,7 @@ fn get_logical_core_count() -> Result { // Check if it's a directory representing a core that can have cpufreq if entry.path().join("cpufreq").exists() { count += 1; - } else if Path::new(&format!("/sys/devices/system/cpu/{}/online", name_str)) + } else if Path::new(&format!("/sys/devices/system/cpu/{name_str}/online")) .exists() { // Fallback for cores that might not have cpufreq but are online (e.g. E-cores on some setups before driver loads) @@ -159,7 +159,7 @@ struct CpuTimes { } impl CpuTimes { - fn total_time(&self) -> u64 { + const fn total_time(&self) -> u64 { self.user + self.nice + self.system @@ -170,7 +170,7 @@ impl CpuTimes { + self.steal } - fn idle_time(&self) -> u64 { + const fn idle_time(&self) -> u64 { self.idle + self.iowait } } @@ -180,20 +180,18 @@ fn read_all_cpu_times() -> Result> { let mut cpu_times_map = HashMap::new(); for line in content.lines() { - if line.starts_with("cpu") && line.chars().nth(3).map_or(false, |c| c.is_digit(10)) { + if line.starts_with("cpu") && line.chars().nth(3).is_some_and(|c| c.is_ascii_digit()) { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 11 { return Err(SysMonitorError::ProcStatParseError(format!( - "Line too short: {}", - line + "Line too short: {line}" ))); } let core_id_str = &parts[0][3..]; let core_id = core_id_str.parse::().map_err(|_| { SysMonitorError::ProcStatParseError(format!( - "Failed to parse core_id: {}", - core_id_str + "Failed to parse core_id: {core_id_str}" )) })?; @@ -270,7 +268,7 @@ pub fn get_cpu_core_info( prev_times: &CpuTimes, current_times: &CpuTimes, ) -> Result { - let cpufreq_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{}/cpufreq/", core_id)); + let cpufreq_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/")); let current_frequency_mhz = read_sysfs_value::(cpufreq_path.join("scaling_cur_freq")) .map(|khz| khz / 1000) @@ -405,21 +403,21 @@ pub fn get_cpu_core_info( fn get_temperature_for_core(hw_path: &Path, core_id: u32, label_prefix: &str) -> Option { for i in 1..=32 { // Increased range to handle systems with many sensors - let label_path = hw_path.join(format!("temp{}_label", i)); - let input_path = hw_path.join(format!("temp{}_input", i)); + let label_path = hw_path.join(format!("temp{i}_label")); + let input_path = hw_path.join(format!("temp{i}_input")); if label_path.exists() && input_path.exists() { if let Ok(label) = read_sysfs_file_trimmed(&label_path) { // Match various common label formats: // "Core X", "core X", "Core-X", "CPU Core X", etc. - let core_pattern = format!("{} {}", label_prefix, core_id); - let alt_pattern = format!("{}-{}", label_prefix, core_id); + let core_pattern = format!("{label_prefix} {core_id}"); + let alt_pattern = format!("{label_prefix}-{core_id}"); if label.eq_ignore_ascii_case(&core_pattern) || label.eq_ignore_ascii_case(&alt_pattern) || label .to_lowercase() - .contains(&format!("core {}", core_id).to_lowercase()) + .contains(&format!("core {core_id}").to_lowercase()) { if let Ok(temp_mc) = read_sysfs_value::(&input_path) { return Some(temp_mc as f32 / 1000.0); @@ -434,8 +432,8 @@ fn get_temperature_for_core(hw_path: &Path, core_id: u32, label_prefix: &str) -> // Finds generic sensor temperatures by label fn get_generic_sensor_temperature(hw_path: &Path, label_name: &str) -> Option { for i in 1..=32 { - let label_path = hw_path.join(format!("temp{}_label", i)); - let input_path = hw_path.join(format!("temp{}_input", i)); + let label_path = hw_path.join(format!("temp{i}_label")); + let input_path = hw_path.join(format!("temp{i}_input")); if label_path.exists() && input_path.exists() { if let Ok(label) = read_sysfs_file_trimmed(&label_path) { @@ -460,7 +458,7 @@ fn get_generic_sensor_temperature(hw_path: &Path, label_name: &str) -> Option Option { for i in 1..=32 { - let input_path = hw_path.join(format!("temp{}_input", i)); + let input_path = hw_path.join(format!("temp{i}_input")); if input_path.exists() { if let Ok(temp_mc) = read_sysfs_value::(&input_path) { @@ -488,12 +486,12 @@ pub fn get_all_cpu_core_info() -> Result> { Ok(info) => core_infos.push(info), Err(e) => { // Log or handle error for a single core, maybe push a partial info or skip - eprintln!("Error getting info for core {}: {}", core_id, e); + eprintln!("Error getting info for core {core_id}: {e}"); } } } else { // Log or handle missing times for a core - eprintln!("Missing CPU time data for core {}", core_id); + eprintln!("Missing CPU time data for core {core_id}"); } } Ok(core_infos) @@ -511,9 +509,7 @@ pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> Result { }; let available_governors = if cpufreq_base.join("scaling_available_governors").exists() { - read_sysfs_file_trimmed(cpufreq_base.join("scaling_available_governors")) - .map(|s| s.split_whitespace().map(String::from).collect()) - .unwrap_or_else(|_| vec![]) + read_sysfs_file_trimmed(cpufreq_base.join("scaling_available_governors")).map_or_else(|_| vec![], |s| s.split_whitespace().map(String::from).collect()) } else { vec![] }; @@ -532,43 +528,46 @@ pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> Result { None }; - let epp = read_sysfs_file_trimmed(cpufreq_base.join("energy_performance_preference")).ok(); + // EPP (Energy Performance Preference) + let energy_perf_pref = + read_sysfs_file_trimmed(cpufreq_base.join("energy_performance_preference")).ok(); - // EPB is often an integer 0-15. Reading as string for now. - let epb = read_sysfs_file_trimmed(cpufreq_base.join("energy_performance_bias")).ok(); + // EPB (Energy Performance Bias) + let energy_perf_bias = + read_sysfs_file_trimmed(cpufreq_base.join("energy_performance_bias")).ok(); let platform_profile = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile").ok(); let _platform_profile_choices = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile_choices").ok(); // Calculate average CPU temperature from the core temperatures - let average_temperature_celsius = if !cpu_cores.is_empty() { + let average_temperature_celsius = if cpu_cores.is_empty() { + None + } else { // Filter cores with temperature readings, then calculate average let cores_with_temp: Vec<&CpuCoreInfo> = cpu_cores .iter() .filter(|core| core.temperature_celsius.is_some()) .collect(); - if !cores_with_temp.is_empty() { + if cores_with_temp.is_empty() { + None + } else { // Sum up all temperatures and divide by count let sum: f32 = cores_with_temp .iter() .map(|core| core.temperature_celsius.unwrap()) .sum(); Some(sum / cores_with_temp.len() as f32) - } else { - None } - } else { - None }; Ok(CpuGlobalInfo { current_governor, available_governors, turbo_status, - epp, - epb, + epp: energy_perf_pref, + epb: energy_perf_bias, platform_profile, average_temperature_celsius, }) @@ -578,14 +577,13 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { let mut batteries = Vec::new(); let power_supply_path = Path::new("/sys/class/power_supply"); - if (!power_supply_path.exists()) { + if !power_supply_path.exists() { return Ok(batteries); // no power supply directory } let ignored_supplies = config .ignored_power_supplies - .as_ref() - .cloned() + .clone() .unwrap_or_default(); // Determine overall AC connection status @@ -649,7 +647,7 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { if let (Some(c), Some(v)) = (current_ua, voltage_uv) { // Power (W) = (Voltage (V) * Current (A)) // (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W - Some((c as f64 * v as f64 / 1_000_000_000_000.0) as f32) + Some((f64::from(c) * f64::from(v) / 1_000_000_000_000.0) as f32) } else { None }