diff --git a/Cargo.lock b/Cargo.lock index 5f41fc6..f077741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-verbosity-flag" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84" +dependencies = [ + "clap", + "log", +] + [[package]] name = "clap_builder" version = "4.5.38" @@ -131,6 +141,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "ctrlc" version = "3.4.7" @@ -141,6 +160,28 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + [[package]] name = "dirs" version = "6.0.0" @@ -220,6 +261,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" + [[package]] name = "indexmap" version = "2.9.0" @@ -230,6 +277,17 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.1", + "libc", + "windows-sys", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -323,7 +381,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", ] @@ -453,7 +511,9 @@ version = "0.3.2" dependencies = [ "anyhow", "clap", + "clap-verbosity-flag", "ctrlc", + "derive_more", "dirs", "env_logger", "jiff", @@ -462,6 +522,7 @@ dependencies = [ "serde", "thiserror", "toml", + "yansi", ] [[package]] @@ -542,6 +603,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -635,3 +708,12 @@ checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] diff --git a/Cargo.toml b/Cargo.toml index 69f9617..aeecd4b 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" @@ -18,3 +18,6 @@ env_logger = "0.11" thiserror = "2.0" anyhow = "1.0" jiff = "0.2.13" +clap-verbosity-flag = "3.0.2" +yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] } +derive_more = { version = "2.0.1", features = ["full"] } diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..2f796b7 --- /dev/null +++ b/config.toml @@ -0,0 +1,2 @@ +[[rule]] +priority = 0 diff --git a/src/battery.rs b/src/battery.rs deleted file mode 100644 index 8fe75dd..0000000 --- a/src/battery.rs +++ /dev/null @@ -1,262 +0,0 @@ -use crate::{config::types::BatteryChargeThresholds, util::error::ControlError, util::sysfs}; -use log::{debug, warn}; -use std::{ - fs, io, - path::{Path, PathBuf}, -}; - -pub type Result = std::result::Result; - -/// Represents a pattern of path suffixes used to control battery charge thresholds -/// for different device vendors. -#[derive(Clone)] -pub struct ThresholdPathPattern { - pub description: &'static str, - pub start_path: &'static str, - pub stop_path: &'static str, -} - -// Threshold patterns -const THRESHOLD_PATTERNS: &[ThresholdPathPattern] = &[ - ThresholdPathPattern { - description: "Standard", - start_path: "charge_control_start_threshold", - stop_path: "charge_control_end_threshold", - }, - ThresholdPathPattern { - description: "ASUS", - start_path: "charge_control_start_percentage", - stop_path: "charge_control_end_percentage", - }, - // Combine Huawei and ThinkPad since they use identical paths - ThresholdPathPattern { - description: "ThinkPad/Huawei", - start_path: "charge_start_threshold", - stop_path: "charge_stop_threshold", - }, - // Framework laptop support - ThresholdPathPattern { - description: "Framework", - start_path: "charge_behaviour_start_threshold", - stop_path: "charge_behaviour_end_threshold", - }, -]; - -/// Represents a battery that supports charge threshold control -pub struct SupportedBattery<'a> { - pub name: String, - pub pattern: &'a ThresholdPathPattern, - pub path: PathBuf, -} - -/// Set battery charge thresholds to protect battery health -/// -/// This sets the start and stop charging thresholds for batteries that support this feature. -/// Different laptop vendors implement battery thresholds in different ways, so this function -/// attempts to handle multiple implementations (Lenovo, ASUS, etc.). -/// -/// The thresholds determine at what percentage the battery starts charging (when below `start_threshold`) -/// and at what percentage it stops (when it reaches `stop_threshold`). -/// -/// # Arguments -/// -/// * `start_threshold` - The battery percentage at which charging should start (typically 0-99) -/// * `stop_threshold` - The battery percentage at which charging should stop (typically 1-100) -/// -/// # Errors -/// -/// Returns an error if: -/// - The thresholds are invalid (start >= stop or stop > 100) -/// - No power supply path is found -/// - No batteries with threshold support are found -/// - Failed to set thresholds on any battery -pub fn set_battery_charge_thresholds(start_threshold: u8, stop_threshold: u8) -> Result<()> { - // Validate thresholds using `BatteryChargeThresholds` - let thresholds = - BatteryChargeThresholds::new(start_threshold, stop_threshold).map_err(|e| match e { - crate::config::types::ConfigError::Validation(msg) => { - ControlError::InvalidValueError(msg) - } - _ => ControlError::InvalidValueError(format!("Invalid battery threshold values: {e}")), - })?; - - let power_supply_path = Path::new("/sys/class/power_supply"); - if !power_supply_path.exists() { - return Err(ControlError::NotSupported( - "Power supply path not found, battery threshold control not supported".to_string(), - )); - } - - // XXX: Skip checking directory writability since /sys is a virtual filesystem - // Individual file writability will be checked by find_battery_with_threshold_support - - let supported_batteries = find_supported_batteries(power_supply_path)?; - if supported_batteries.is_empty() { - return Err(ControlError::NotSupported( - "No batteries with charge threshold control support found".to_string(), - )); - } - - apply_thresholds_to_batteries(&supported_batteries, thresholds.start, thresholds.stop) -} - -/// Finds all batteries in the system that support threshold control -fn find_supported_batteries(power_supply_path: &Path) -> Result>> { - let entries = fs::read_dir(power_supply_path).map_err(|e| { - if e.kind() == io::ErrorKind::PermissionDenied { - ControlError::PermissionDenied(format!( - "Permission denied accessing power supply directory: {}", - power_supply_path.display() - )) - } else { - ControlError::Io(e) - } - })?; - - let mut supported_batteries = Vec::new(); - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(e) => { - warn!("Failed to read power-supply entry: {e}"); - continue; - } - }; - let ps_path = entry.path(); - if is_battery(&ps_path)? { - if let Some(battery) = find_battery_with_threshold_support(&ps_path) { - supported_batteries.push(battery); - } - } - } - - if supported_batteries.is_empty() { - warn!("No batteries with charge threshold support found"); - } else { - debug!( - "Found {} batteries with threshold support", - supported_batteries.len() - ); - for battery in &supported_batteries { - debug!( - "Battery '{}' supports {} threshold control", - battery.name, battery.pattern.description - ); - } - } - - Ok(supported_batteries) -} - -/// Applies the threshold settings to all supported batteries -fn apply_thresholds_to_batteries( - batteries: &[SupportedBattery<'_>], - start_threshold: u8, - stop_threshold: u8, -) -> Result<()> { - let mut errors = Vec::new(); - let mut success_count = 0; - - for battery in batteries { - let start_path = battery.path.join(battery.pattern.start_path); - let stop_path = battery.path.join(battery.pattern.stop_path); - - // Read current thresholds in case we need to restore them - let current_stop = sysfs::read_sysfs_value(&stop_path).ok(); - - // Write stop threshold first (must be >= start threshold) - let stop_result = sysfs::write_sysfs_value(&stop_path, &stop_threshold.to_string()); - - // Only proceed to set start threshold if stop threshold was set successfully - if matches!(stop_result, Ok(())) { - let start_result = sysfs::write_sysfs_value(&start_path, &start_threshold.to_string()); - - match start_result { - Ok(()) => { - debug!( - "Set {}-{}% charge thresholds for {} battery '{}'", - start_threshold, stop_threshold, battery.pattern.description, battery.name - ); - success_count += 1; - } - Err(e) => { - // Start threshold failed, try to restore the previous stop threshold - if let Some(prev_stop) = ¤t_stop { - let restore_result = sysfs::write_sysfs_value(&stop_path, prev_stop); - if let Err(re) = restore_result { - warn!( - "Failed to restore previous stop threshold for battery '{}': {}. Battery may be in an inconsistent state.", - battery.name, re - ); - } else { - debug!( - "Restored previous stop threshold ({}) for battery '{}'", - prev_stop, battery.name - ); - } - } - - errors.push(format!( - "Failed to set start threshold for {} battery '{}': {}", - battery.pattern.description, battery.name, e - )); - } - } - } else if let Err(e) = stop_result { - errors.push(format!( - "Failed to set stop threshold for {} battery '{}': {}", - battery.pattern.description, battery.name, e - )); - } - } - - if success_count > 0 { - if !errors.is_empty() { - warn!( - "Partial success setting battery thresholds: {}", - errors.join("; ") - ); - } - Ok(()) - } else { - Err(ControlError::WriteError(format!( - "Failed to set charge thresholds on any battery: {}", - errors.join("; ") - ))) - } -} - -/// Determines if a power supply entry is a battery -fn is_battery(path: &Path) -> Result { - let type_path = path.join("type"); - - if !type_path.exists() { - return Ok(false); - } - - let ps_type = sysfs::read_sysfs_value(&type_path).map_err(|e| { - ControlError::ReadError(format!("Failed to read {}: {}", type_path.display(), e)) - })?; - - Ok(ps_type == "Battery") -} - -/// Identifies if a battery supports threshold control and which pattern it uses -fn find_battery_with_threshold_support(ps_path: &Path) -> Option> { - for pattern in THRESHOLD_PATTERNS { - let start_threshold_path = ps_path.join(pattern.start_path); - let stop_threshold_path = ps_path.join(pattern.stop_path); - - // Ensure both paths exist and are writable before considering this battery supported - if sysfs::path_exists_and_writable(&start_threshold_path) - && sysfs::path_exists_and_writable(&stop_threshold_path) - { - return Some(SupportedBattery { - name: ps_path.file_name()?.to_string_lossy().to_string(), - pattern, - path: ps_path.to_path_buf(), - }); - } - } - None -} diff --git a/src/cli/debug.rs b/src/cli/debug.rs deleted file mode 100644 index 17cec0c..0000000 --- a/src/cli/debug.rs +++ /dev/null @@ -1,265 +0,0 @@ -use crate::config::AppConfig; -use crate::cpu; -use crate::monitor; -use crate::util::error::AppError; -use std::fs; -use std::process::{Command, Stdio}; -use std::time::Duration; - -/// Prints comprehensive debug information about the system -pub fn run_debug(config: &AppConfig) -> Result<(), AppError> { - println!("=== SUPERFREQ DEBUG INFORMATION ==="); - println!("Version: {}", env!("CARGO_PKG_VERSION")); - - // Current date and time - println!("Timestamp: {}", jiff::Timestamp::now()); - - // Kernel information - if let Ok(kernel_info) = get_kernel_info() { - println!("Kernel Version: {kernel_info}"); - } else { - println!("Kernel Version: Unable to determine"); - } - - // System uptime - if let Ok(uptime) = get_system_uptime() { - println!( - "System Uptime: {} hours, {} minutes", - uptime.as_secs() / 3600, - (uptime.as_secs() % 3600) / 60 - ); - } else { - println!("System Uptime: Unable to determine"); - } - - // Get system information - match monitor::collect_system_report(config) { - Ok(report) => { - println!("\n--- SYSTEM INFORMATION ---"); - println!("CPU Model: {}", report.system_info.cpu_model); - println!("Architecture: {}", report.system_info.architecture); - println!( - "Linux Distribution: {}", - report.system_info.linux_distribution - ); - - println!("\n--- CONFIGURATION ---"); - println!("Current Configuration: {config:#?}"); - - // Print important sysfs paths and whether they exist - println!("\n--- SYSFS PATHS ---"); - check_and_print_sysfs_path( - "/sys/devices/system/cpu/intel_pstate/no_turbo", - "Intel P-State Turbo Control", - ); - check_and_print_sysfs_path( - "/sys/devices/system/cpu/cpufreq/boost", - "Generic CPU Boost Control", - ); - check_and_print_sysfs_path( - "/sys/devices/system/cpu/amd_pstate/cpufreq/boost", - "AMD P-State Boost Control", - ); - check_and_print_sysfs_path( - "/sys/firmware/acpi/platform_profile", - "ACPI Platform Profile Control", - ); - check_and_print_sysfs_path("/sys/class/power_supply", "Power Supply Information"); - - println!("\n--- CPU INFORMATION ---"); - println!("Current Governor: {:?}", report.cpu_global.current_governor); - println!( - "Available Governors: {}", - report.cpu_global.available_governors.join(", ") - ); - println!("Turbo Status: {:?}", report.cpu_global.turbo_status); - println!( - "Energy Performance Preference (EPP): {:?}", - report.cpu_global.epp - ); - println!("Energy Performance Bias (EPB): {:?}", report.cpu_global.epb); - - // Add governor override information - if let Some(override_governor) = cpu::get_governor_override() { - println!("Governor Override: {}", override_governor.trim()); - } else { - println!("Governor Override: None"); - } - - println!("\n--- PLATFORM PROFILE ---"); - println!( - "Current Platform Profile: {:?}", - report.cpu_global.platform_profile - ); - match cpu::get_platform_profiles() { - Ok(profiles) => println!("Available Platform Profiles: {}", profiles.join(", ")), - Err(_) => println!("Available Platform Profiles: Not supported on this system"), - } - - println!("\n--- CPU CORES DETAIL ---"); - println!("Total CPU Cores: {}", report.cpu_cores.len()); - for core in &report.cpu_cores { - println!("Core {}:", core.core_id); - println!( - " Current Frequency: {} MHz", - core.current_frequency_mhz - .map_or_else(|| "N/A".to_string(), |f| f.to_string()) - ); - println!( - " Min Frequency: {} MHz", - core.min_frequency_mhz - .map_or_else(|| "N/A".to_string(), |f| f.to_string()) - ); - println!( - " Max Frequency: {} MHz", - core.max_frequency_mhz - .map_or_else(|| "N/A".to_string(), |f| f.to_string()) - ); - println!( - " Usage: {}%", - core.usage_percent - .map_or_else(|| "N/A".to_string(), |u| format!("{u:.1}")) - ); - println!( - " Temperature: {}°C", - core.temperature_celsius - .map_or_else(|| "N/A".to_string(), |t| format!("{t:.1}")) - ); - } - - println!("\n--- TEMPERATURE INFORMATION ---"); - println!( - "Average CPU Temperature: {}", - report.cpu_global.average_temperature_celsius.map_or_else( - || "N/A (CPU temperature sensor not detected)".to_string(), - |t| format!("{t:.1}°C") - ) - ); - - println!("\n--- BATTERY INFORMATION ---"); - if report.batteries.is_empty() { - println!("No batteries found or all are ignored."); - } else { - for battery in &report.batteries { - println!("Battery: {}", battery.name); - println!(" AC Connected: {}", battery.ac_connected); - println!( - " Charging State: {}", - battery.charging_state.as_deref().unwrap_or("N/A") - ); - println!( - " Capacity: {}%", - battery - .capacity_percent - .map_or_else(|| "N/A".to_string(), |c| c.to_string()) - ); - println!( - " Power Rate: {} W", - battery - .power_rate_watts - .map_or_else(|| "N/A".to_string(), |p| format!("{p:.2}")) - ); - println!( - " Charge Start Threshold: {}", - battery - .charge_start_threshold - .map_or_else(|| "N/A".to_string(), |t| t.to_string()) - ); - println!( - " Charge Stop Threshold: {}", - battery - .charge_stop_threshold - .map_or_else(|| "N/A".to_string(), |t| t.to_string()) - ); - } - } - - println!("\n--- SYSTEM LOAD ---"); - println!( - "Load Average (1 min): {:.2}", - report.system_load.load_avg_1min - ); - println!( - "Load Average (5 min): {:.2}", - report.system_load.load_avg_5min - ); - println!( - "Load Average (15 min): {:.2}", - report.system_load.load_avg_15min - ); - - println!("\n--- DAEMON STATUS ---"); - // Simple check for daemon status - can be expanded later - let daemon_status = fs::metadata("/var/run/superfreq.pid").is_ok(); - println!("Daemon Running: {daemon_status}"); - - // Check for systemd service status - if let Ok(systemd_status) = is_systemd_service_active("superfreq") { - println!("Systemd Service Active: {systemd_status}"); - } - - Ok(()) - } - Err(e) => Err(AppError::Monitor(e)), - } -} - -/// Get kernel version information -fn get_kernel_info() -> Result { - let output = Command::new("uname") - .arg("-r") - .output() - .map_err(AppError::Io)?; - - let kernel_version = String::from_utf8(output.stdout) - .map_err(|e| AppError::Generic(format!("Failed to parse kernel version: {e}")))?; - Ok(kernel_version.trim().to_string()) -} - -/// Get system uptime -fn get_system_uptime() -> Result { - let uptime_str = fs::read_to_string("/proc/uptime").map_err(AppError::Io)?; - let uptime_secs = uptime_str - .split_whitespace() - .next() - .ok_or_else(|| AppError::Generic("Invalid format in /proc/uptime file".to_string()))? - .parse::() - .map_err(|e| AppError::Generic(format!("Failed to parse uptime from /proc/uptime: {e}")))?; - - Ok(Duration::from_secs_f64(uptime_secs)) -} - -/// Check if a sysfs path exists and print its status -fn check_and_print_sysfs_path(path: &str, description: &str) { - let exists = std::path::Path::new(path).exists(); - println!( - "{}: {} ({})", - description, - path, - if exists { "Exists" } else { "Not Found" } - ); -} - -/// Check if a systemd service is active -fn is_systemd_service_active(service_name: &str) -> Result { - let output = Command::new("systemctl") - .arg("is-active") - .arg(format!("{service_name}.service")) - .stdout(Stdio::piped()) // capture stdout instead of letting it print - .stderr(Stdio::null()) // redirect stderr to null - .output() - .map_err(AppError::Io)?; - - // Check if the command executed successfully - if !output.status.success() { - // Command failed - service is either not found or not active - return Ok(false); - } - - // Command executed successfully, now check the output content - let status = String::from_utf8(output.stdout) - .map_err(|e| AppError::Generic(format!("Failed to parse systemctl output: {e}")))?; - - // Explicitly verify the output is "active" - Ok(status.trim() == "active") -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs deleted file mode 100644 index 2f36523..0000000 --- a/src/cli/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod debug; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2560806 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,297 @@ +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() +} + +#[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, +} + +impl CpuDelta { + pub fn apply(&self) -> anyhow::Result<()> { + let mut cpus = match &self.for_ { + Some(numbers) => { + let mut cpus = Vec::with_capacity(numbers.len()); + let cache = cpu::CpuRescanCache::default(); + + for &number in numbers { + cpus.push(cpu::Cpu::new(number, &cache)?); + } + + cpus + } + None => cpu::Cpu::all().context("failed to get all CPUs and their information")?, + }; + + for cpu in &mut 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_mhz_minimum(mhz_minimum)?; + } + + if let Some(mhz_maximum) = self.frequency_mhz_maximum { + cpu.set_frequency_mhz_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 { + /// 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, +} + +impl PowerDelta { + pub fn apply(&self) -> anyhow::Result<()> { + let mut 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 &mut power_supplies { + if let Some(threshold_start) = self.charge_threshold_start { + power_supply.set_charge_threshold_start(threshold_start as f64 / 100.0)?; + } + + if let Some(threshold_end) = self.charge_threshold_end { + power_supply.set_charge_threshold_end(threshold_end as f64 / 100.0)?; + } + } + + 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)] +#[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-temperature-volatility")] + CpuTemperatureVolatility, + + #[serde(rename = "$cpu-idle-seconds")] + CpuIdleSeconds, + + #[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, + + Number(f64), + + 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)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Rule { + priority: u8, + + #[serde(default, rename = "if", skip_serializing_if = "is_default")] + if_: Expression, + + #[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)] +#[serde(default, rename_all = "kebab-case")] +pub struct DaemonConfig { + #[serde(rename = "rule")] + rules: 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.rules.len()); + + for rule in &config.rules { + if priorities.contains(&rule.priority) { + bail!("each config rule must have a different priority") + } + + priorities.push(rule.priority); + } + } + + Ok(config) + } +} diff --git a/src/config/load.rs b/src/config/load.rs deleted file mode 100644 index 51f7e22..0000000 --- a/src/config/load.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Configuration loading functionality -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::config::types::{AppConfig, AppConfigToml, ConfigError, 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() -> 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>) -> 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); - } - return Err(ConfigError::Io(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) -> Result { - let contents = fs::read_to_string(path).map_err(ConfigError::Io)?; - - let toml_app_config = toml::from_str::(&contents).map_err(ConfigError::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 eb9ce7f..0000000 --- a/src/config/types.rs +++ /dev/null @@ -1,312 +0,0 @@ -// Configuration types and structures for superfreq -use crate::core::TurboSetting; -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 BatteryChargeThresholds { - pub start: u8, - pub stop: u8, -} - -impl BatteryChargeThresholds { - pub fn new(start: u8, stop: u8) -> Result { - if stop == 0 { - return Err(ConfigError::Validation( - "Stop threshold must be greater than 0%".to_string(), - )); - } - if start >= stop { - return Err(ConfigError::Validation(format!( - "Start threshold ({start}) must be less than stop threshold ({stop})" - ))); - } - if stop > 100 { - return Err(ConfigError::Validation(format!( - "Stop threshold ({stop}) cannot exceed 100%" - ))); - } - - Ok(Self { start, stop }) - } -} - -impl TryFrom<(u8, u8)> for BatteryChargeThresholds { - type Error = ConfigError; - - fn try_from(values: (u8, u8)) -> Result { - let (start, stop) = values; - Self::new(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: 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 - 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, -} - -// Error type for config loading -#[derive(Debug, thiserror::Error)] -pub enum ConfigError { - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("TOML parsing error: {0}")] - Toml(#[from] toml::de::Error), - - #[error("Configuration validation error: {0}")] - Validation(String), -} - -// 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_const!(default_load_threshold_low, f32, DEFAULT_LOAD_THRESHOLD_LOW); -default_const!( - default_temp_threshold_high, - f32, - DEFAULT_TEMP_THRESHOLD_HIGH -); -default_const!( - 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(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: 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_const!(default_adaptive_interval, bool, false); -default_const!(default_min_poll_interval_sec, u64, 1); -default_const!(default_max_poll_interval_sec, u64, 30); -default_const!(default_throttle_on_battery, bool, true); -default_const!(default_log_level, LogLevel, LogLevel::Info); -default_const!(default_stats_file_path, Option, None); -default_const!(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/core.rs b/src/core.rs index 76dc940..2e32854 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,56 +1,18 @@ -use clap::ValueEnum; -use serde::{Deserialize, Serialize}; -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, 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, - pub architecture: String, - pub linux_distribution: String, } pub struct CpuCoreInfo { // Per-core data pub core_id: u32, - pub current_frequency_mhz: Option, - pub min_frequency_mhz: Option, - pub max_frequency_mhz: Option, - pub usage_percent: Option, pub temperature_celsius: Option, } pub struct CpuGlobalInfo { // System-wide CPU settings - pub current_governor: Option, - pub available_governors: Vec, - pub turbo_status: Option, // true for enabled, false for disabled - pub epp: Option, // Energy Performance Preference - pub epb: Option, // Energy Performance Bias - pub platform_profile: Option, + pub epp: Option, // Energy Performance Preference + pub epb: Option, // Energy Performance Bias pub average_temperature_celsius: Option, // Average temperature across all cores } diff --git a/src/cpu.rs b/src/cpu.rs index 5629df3..3c0f332 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,621 +1,642 @@ -use crate::core::{GovernorOverrideMode, TurboSetting}; -use crate::util::error::ControlError; -use core::str; -use log::debug; -use std::{fs, io, path::Path, string::ToString}; +use anyhow::{Context, bail}; +use yansi::Paint as _; -pub type Result = std::result::Result; +use std::{cell::OnceCell, collections::HashMap, fmt, string::ToString}; -// Valid EPB string values -const VALID_EPB_STRINGS: &[&str] = &[ - "performance", - "balance-performance", - "balance_performance", // alternative form - "balance-power", - "balance_power", // alternative form - "power", -]; +use crate::fs; -// EPP (Energy Performance Preference) string values -const EPP_FALLBACK_VALUES: &[&str] = &[ - "default", - "performance", - "balance-performance", - "balance_performance", // alternative form with underscore - "balance-power", - "balance_power", // alternative form with underscore - "power", -]; - -// Write a value to a sysfs file -fn write_sysfs_value(path: impl AsRef, value: &str) -> Result<()> { - let p = path.as_ref(); - - fs::write(p, value).map_err(|e| { - let error_msg = format!("Path: {:?}, Value: '{}', Error: {}", p.display(), value, e); - match e.kind() { - io::ErrorKind::PermissionDenied => ControlError::PermissionDenied(error_msg), - io::ErrorKind::NotFound => { - ControlError::PathMissing(format!("Path '{}' does not exist", p.display())) - } - _ => ControlError::WriteError(error_msg), - } - }) +#[derive(Default, Debug, Clone, PartialEq)] +pub struct CpuRescanCache { + stat: OnceCell>, + temperatures: OnceCell>, } -pub fn get_logical_core_count() -> Result { - // Using num_cpus::get() for a reliable count of logical cores accessible. - // The monitor module's get_logical_core_count might be more specific to cpufreq-capable cores, - // but for applying settings, we might want to iterate over all reported by OS. - // However, settings usually apply to cores with cpufreq. - // Let's use a similar discovery to monitor's get_logical_core_count - let mut num_cores: u32 = 0; - let path = Path::new("/sys/devices/system/cpu"); - if !path.exists() { - return Err(ControlError::NotSupported(format!( - "No logical cores found at {}.", - path.display() - ))); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CpuStat { + pub user: u64, + pub nice: u64, + pub system: u64, + pub idle: u64, + pub iowait: u64, + pub irq: u64, + pub softirq: u64, + pub steal: u64, +} + +impl CpuStat { + pub fn total(&self) -> u64 { + self.user + + self.nice + + self.system + + self.idle + + self.iowait + + self.irq + + self.softirq + + self.steal } - let entries = fs::read_dir(path) - .map_err(|_| { - ControlError::PermissionDenied(format!("Cannot read contents of {}.", path.display())) - })? - .flatten(); + pub fn idle(&self) -> u64 { + self.idle + self.iowait + } - for entry in entries { - let entry_file_name = entry.file_name(); - let Some(name) = entry_file_name.to_str() else { - continue; + pub fn usage(&self) -> f64 { + 1.0 - self.idle() as f64 / self.total() as f64 + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Cpu { + pub number: u32, + + pub has_cpufreq: bool, + + pub available_governors: Vec, + pub governor: Option, + + pub frequency_mhz: Option, + pub frequency_mhz_minimum: Option, + pub frequency_mhz_maximum: Option, + + pub available_epps: Vec, + pub epp: Option, + + pub available_epbs: Vec, + pub epb: Option, + + pub stat: CpuStat, + + pub temperature: Option, +} + +impl fmt::Display for Cpu { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let number = self.number.cyan(); + + write!(f, "CPU {number}") + } +} + +impl Cpu { + pub fn new(number: u32, cache: &CpuRescanCache) -> anyhow::Result { + let mut cpu = Self { + number, + has_cpufreq: false, + + available_governors: Vec::new(), + governor: None, + + frequency_mhz: None, + frequency_mhz_minimum: None, + frequency_mhz_maximum: None, + + available_epps: Vec::new(), + epp: None, + + available_epbs: Vec::new(), + epb: None, + + stat: CpuStat { + user: 0, + nice: 0, + system: 0, + idle: 0, + iowait: 0, + irq: 0, + softirq: 0, + steal: 0, + }, + + temperature: None, }; + cpu.rescan(cache)?; - // Skip non-CPU directories (e.g., cpuidle, cpufreq) - if !name.starts_with("cpu") || name.len() <= 3 || !name[3..].chars().all(char::is_numeric) { - continue; - } - - if !entry.path().join("cpufreq").exists() { - continue; - } - - if name[3..].parse::().is_ok() { - num_cores += 1; - } - } - if num_cores == 0 { - // Fallback if sysfs iteration above fails to find any cpufreq cores - num_cores = num_cpus::get() as u32; + Ok(cpu) } - Ok(num_cores) -} + /// Get all CPUs. + pub fn all() -> anyhow::Result> { + const PATH: &str = "/sys/devices/system/cpu"; -fn for_each_cpu_core(mut action: F) -> Result<()> -where - F: FnMut(u32) -> Result<()>, -{ - let num_cores: u32 = get_logical_core_count()?; + let mut cpus = vec![]; + let cache = CpuRescanCache::default(); - for core_id in 0u32..num_cores { - action(core_id)?; - } - Ok(()) -} + for entry in fs::read_dir(PATH) + .with_context(|| format!("failed to read CPU entries from '{PATH}'"))? + .with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))? + { + let entry = entry.with_context(|| format!("failed to read entry of '{PATH}'"))?; -pub fn set_governor(governor: &str, core_id: Option) -> Result<()> { - // Validate the governor is available on this system - // This returns both the validation result and the list of available governors - let (is_valid, available_governors) = is_governor_valid(governor)?; + let entry_file_name = entry.file_name(); - if !is_valid { - return Err(ControlError::InvalidGovernor(format!( - "Governor '{}' is not available on this system. Valid governors: {}", - governor, - available_governors.join(", ") - ))); - } - - let action = |id: u32| { - let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_governor"); - if Path::new(&path).exists() { - write_sysfs_value(&path, governor) - } else { - // Silently ignore if the path doesn't exist for a specific core, - // as not all cores might have cpufreq (e.g. offline cores) - Ok(()) - } - }; - - core_id.map_or_else(|| for_each_cpu_core(action), action) -} - -/// Check if the provided governor is available in the system -/// Returns a tuple of (`is_valid`, `available_governors`) to avoid redundant file reads -fn is_governor_valid(governor: &str) -> Result<(bool, Vec)> { - let governors = get_available_governors()?; - - // Convert input governor to lowercase for case-insensitive comparison - let governor_lower = governor.to_lowercase(); - - // Convert all available governors to lowercase for comparison - let governors_lower: Vec = governors.iter().map(|g| g.to_lowercase()).collect(); - - // Check if the lowercase governor is in the lowercase list - Ok((governors_lower.contains(&governor_lower), governors)) -} - -/// Get available CPU governors from the system -fn get_available_governors() -> Result> { - let cpu_base_path = Path::new("/sys/devices/system/cpu"); - - // First try the traditional path with cpu0. This is the most common case - // and will usually catch early, but we should try to keep the code to handle - // "edge" cases lightweight, for the (albeit smaller) number of users that - // run Superfreq on unusual systems. - let cpu0_path = "/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors"; - if Path::new(cpu0_path).exists() { - let content = fs::read_to_string(cpu0_path).map_err(|e| { - ControlError::ReadError(format!("Failed to read available governors from cpu0: {e}")) - })?; - - let governors: Vec = content - .split_whitespace() - .map(ToString::to_string) - .collect(); - - if !governors.is_empty() { - return Ok(governors); - } - } - - // If cpu0 doesn't have the file or it's empty, scan all CPUs - // This handles heterogeneous systems where cpu0 might not have cpufreq - if let Ok(entries) = fs::read_dir(cpu_base_path) { - for entry in entries.flatten() { - let path = entry.path(); - let file_name = entry.file_name(); - let name = match file_name.to_str() { - Some(name) => name, - None => continue, + let Some(name) = entry_file_name.to_str() else { + continue; }; - // Skip non-CPU directories - if !name.starts_with("cpu") - || name.len() <= 3 - || !name[3..].chars().all(char::is_numeric) - { + let Some(cpu_prefix_removed) = name.strip_prefix("cpu") else { continue; - } + }; - let governor_path = path.join("cpufreq/scaling_available_governors"); - if governor_path.exists() { - match fs::read_to_string(&governor_path) { - Ok(content) => { - let governors: Vec = content - .split_whitespace() - .map(ToString::to_string) - .collect(); + // Has to match "cpu{N}". + let Ok(number) = cpu_prefix_removed.parse() else { + continue; + }; - if !governors.is_empty() { - return Ok(governors); - } - } - Err(_) => continue, // try next CPU if this one fails - } + cpus.push(Self::new(number, &cache)?); + } + + // Fall back if sysfs iteration above fails to find any cpufreq CPUs. + if cpus.is_empty() { + for number in 0..num_cpus::get() as u32 { + cpus.push(Self::new(number, &cache)?); } } + + Ok(cpus) } - // If we get here, we couldn't find any valid governors list - Err(ControlError::NotSupported( - "Could not determine available governors on any CPU".to_string(), - )) -} + /// Rescan CPU, tuning local copy of settings. + pub fn rescan(&mut self, cache: &CpuRescanCache) -> anyhow::Result<()> { + let Self { number, .. } = self; -pub fn set_turbo(setting: TurboSetting) -> Result<()> { - let value_pstate = match setting { - TurboSetting::Always => "0", // no_turbo = 0 means turbo is enabled - TurboSetting::Never => "1", // no_turbo = 1 means turbo is disabled - // Auto mode is handled at the engine level, not directly at the sysfs level - TurboSetting::Auto => { - debug!("Turbo Auto mode is managed by engine logic based on system conditions"); - return Ok(()); + if !fs::exists(format!("/sys/devices/system/cpu/cpu{number}")) { + bail!("{self} does not exist"); } - }; - let value_boost = match setting { - TurboSetting::Always => "1", // boost = 1 means turbo is enabled - TurboSetting::Never => "0", // boost = 0 means turbo is disabled - TurboSetting::Auto => { - debug!("Turbo Auto mode is managed by engine logic based on system conditions"); - return Ok(()); + + self.has_cpufreq = fs::exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); + + if self.has_cpufreq { + self.rescan_governor()?; + self.rescan_frequency()?; + self.rescan_epp()?; + self.rescan_epb()?; } - }; - // AMD specific paths - let amd_pstate_path = "/sys/devices/system/cpu/amd_pstate/cpufreq/boost"; - let msr_boost_path = "/sys/devices/system/cpu/cpufreq/amd_pstate_enable_boost"; + self.rescan_stat(cache)?; + self.rescan_temperature(cache)?; - // Path priority (from most to least specific) - let pstate_path = "/sys/devices/system/cpu/intel_pstate/no_turbo"; - let boost_path = "/sys/devices/system/cpu/cpufreq/boost"; - - // Try each boost control path in order of specificity - if Path::new(pstate_path).exists() { - write_sysfs_value(pstate_path, value_pstate) - } else if Path::new(amd_pstate_path).exists() { - write_sysfs_value(amd_pstate_path, value_boost) - } else if Path::new(msr_boost_path).exists() { - write_sysfs_value(msr_boost_path, value_boost) - } else if Path::new(boost_path).exists() { - write_sysfs_value(boost_path, value_boost) - } else { - // Also try per-core cpufreq boost for some AMD systems - let result = try_set_per_core_boost(value_boost)?; - if result { - Ok(()) - } else { - Err(ControlError::NotSupported( - "No supported CPU boost control mechanism found.".to_string(), - )) - } - } -} - -/// Try to set boost on a per-core basis for systems that support it -fn try_set_per_core_boost(value: &str) -> Result { - let mut success = false; - let num_cores = get_logical_core_count()?; - - for core_id in 0..num_cores { - let boost_path = format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/boost"); - - if Path::new(&boost_path).exists() { - write_sysfs_value(&boost_path, value)?; - success = true; - } - } - - Ok(success) -} - -pub fn set_epp(epp: &str, core_id: Option) -> Result<()> { - // Validate the EPP value against available options - let available_epp = get_available_epp_values()?; - if !available_epp.iter().any(|v| v.eq_ignore_ascii_case(epp)) { - return Err(ControlError::InvalidValueError(format!( - "Invalid EPP value: '{}'. Available values: {}", - epp, - available_epp.join(", ") - ))); - } - - let action = |id: u32| { - let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_preference"); - if Path::new(&path).exists() { - write_sysfs_value(&path, epp) - } else { - Ok(()) - } - }; - core_id.map_or_else(|| for_each_cpu_core(action), action) -} - -/// Get available EPP values from the system -fn get_available_epp_values() -> Result> { - let path = "/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_available_preferences"; - - if !Path::new(path).exists() { - // If the file doesn't exist, fall back to a default set of common values - // This is safer than failing outright, as some systems may allow these values │ - // even without explicitly listing them - return Ok(EPP_FALLBACK_VALUES.iter().map(|&s| s.to_string()).collect()); - } - - let content = fs::read_to_string(path).map_err(|e| { - ControlError::ReadError(format!("Failed to read available EPP values: {e}")) - })?; - - Ok(content - .split_whitespace() - .map(ToString::to_string) - .collect()) -} - -pub fn set_epb(epb: &str, core_id: Option) -> Result<()> { - // Validate EPB value - should be a number 0-15 or a recognized string value - validate_epb_value(epb)?; - - let action = |id: u32| { - let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_bias"); - if Path::new(&path).exists() { - write_sysfs_value(&path, epb) - } else { - Ok(()) - } - }; - core_id.map_or_else(|| for_each_cpu_core(action), action) -} - -fn validate_epb_value(epb: &str) -> Result<()> { - // EPB can be a number from 0-15 or a recognized string - // Try parsing as a number first - if let Ok(value) = epb.parse::() { - if value <= 15 { - return Ok(()); - } - return Err(ControlError::InvalidValueError(format!( - "EPB numeric value must be between 0 and 15, got {value}" - ))); - } - - // If not a number, check if it's a recognized string value. - // This is using case-insensitive comparison - if VALID_EPB_STRINGS - .iter() - .any(|valid| valid.eq_ignore_ascii_case(epb)) - { Ok(()) - } else { - Err(ControlError::InvalidValueError(format!( - "Invalid EPB value: '{}'. Must be a number 0-15 or one of: {}", - epb, - VALID_EPB_STRINGS.join(", ") - ))) - } -} - -pub fn set_min_frequency(freq_mhz: u32, core_id: Option) -> Result<()> { - // Check if the new minimum frequency would be greater than current maximum - if let Some(id) = core_id { - validate_min_frequency(id, freq_mhz)?; - } else { - // Check for all cores - let num_cores = get_logical_core_count()?; - for id in 0..num_cores { - validate_min_frequency(id, freq_mhz)?; - } } - // XXX: We use u64 for the intermediate calculation to prevent overflow - let freq_khz = u64::from(freq_mhz) * 1000; - let freq_khz_str = freq_khz.to_string(); + fn rescan_governor(&mut self) -> anyhow::Result<()> { + let Self { number, .. } = *self; - let action = |id: u32| { - let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_min_freq"); - if Path::new(&path).exists() { - write_sysfs_value(&path, &freq_khz_str) + self.available_governors = 'available_governors: { + let Some(content) = fs::read(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_available_governors" + )) + .with_context(|| format!("failed to read {self} available governors"))? + else { + break 'available_governors Vec::new(); + }; + + content + .split_whitespace() + .map(ToString::to_string) + .collect() + }; + + self.governor = Some( + fs::read(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_governor" + )) + .with_context(|| format!("failed to read {self} scaling governor"))? + .with_context(|| format!("failed to find {self} scaling governor"))?, + ); + + Ok(()) + } + + fn rescan_frequency(&mut self) -> anyhow::Result<()> { + let Self { number, .. } = *self; + + let frequency_khz = fs::read_n::(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_cur_freq" + )) + .with_context(|| format!("failed to parse {self} frequency"))? + .with_context(|| format!("failed to find {self} frequency"))?; + let frequency_khz_minimum = fs::read_n::(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" + )) + .with_context(|| format!("failed to parse {self} frequency minimum"))? + .with_context(|| format!("failed to find {self} frequency"))?; + let frequency_khz_maximum = fs::read_n::(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_max_freq" + )) + .with_context(|| format!("failed to parse {self} frequency maximum"))? + .with_context(|| format!("failed to find {self} frequency"))?; + + self.frequency_mhz = Some(frequency_khz / 1000); + self.frequency_mhz_minimum = Some(frequency_khz_minimum / 1000); + self.frequency_mhz_maximum = Some(frequency_khz_maximum / 1000); + + Ok(()) + } + + fn rescan_epp(&mut self) -> anyhow::Result<()> { + let Self { number, .. } = *self; + + self.available_epps = 'available_epps: { + let Some(content) = fs::read(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_available_preferences" + )).with_context(|| format!("failed to read {self} available EPPs"))? else { + break 'available_epps Vec::new(); + }; + + content + .split_whitespace() + .map(ToString::to_string) + .collect() + }; + + self.epp = Some( + fs::read(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_preference" + )) + .with_context(|| format!("failed to read {self} EPP"))? + .with_context(|| format!("failed to find {self} EPP"))?, + ); + + Ok(()) + } + + fn rescan_epb(&mut self) -> anyhow::Result<()> { + let Self { number, .. } = self; + + self.available_epbs = if self.has_cpufreq { + vec![ + "1".to_owned(), + "2".to_owned(), + "3".to_owned(), + "4".to_owned(), + "5".to_owned(), + "6".to_owned(), + "7".to_owned(), + "8".to_owned(), + "9".to_owned(), + "10".to_owned(), + "11".to_owned(), + "12".to_owned(), + "13".to_owned(), + "14".to_owned(), + "15".to_owned(), + "performance".to_owned(), + "balance-performance".to_owned(), + "balance_performance".to_owned(), // Alternative form with underscore. + "balance-power".to_owned(), + "balance_power".to_owned(), // Alternative form with underscore. + "power".to_owned(), + ] } else { - Ok(()) - } - }; - core_id.map_or_else(|| for_each_cpu_core(action), action) -} + Vec::new() + }; -pub fn set_max_frequency(freq_mhz: u32, core_id: Option) -> Result<()> { - // Check if the new maximum frequency would be less than current minimum - if let Some(id) = core_id { - validate_max_frequency(id, freq_mhz)?; - } else { - // Check for all cores - let num_cores = get_logical_core_count()?; - for id in 0..num_cores { - validate_max_frequency(id, freq_mhz)?; - } + self.epb = Some( + fs::read(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_bias" + )) + .with_context(|| format!("failed to read {self} EPB"))? + .with_context(|| format!("failed to find {self} EPB"))?, + ); + + Ok(()) } - // XXX: Use a u64 here as well. - let freq_khz = u64::from(freq_mhz) * 1000; - let freq_khz_str = freq_khz.to_string(); + fn rescan_stat(&mut self, cache: &CpuRescanCache) -> anyhow::Result<()> { + // OnceCell::get_or_try_init is unstable. Cope: + let stat = match cache.stat.get() { + Some(stat) => stat, - let action = |id: u32| { - let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_max_freq"); - if Path::new(&path).exists() { - write_sysfs_value(&path, &freq_khz_str) - } else { - Ok(()) - } - }; - core_id.map_or_else(|| for_each_cpu_core(action), action) -} + None => { + let content = fs::read("/proc/stat") + .context("failed to read CPU stat")? + .context("/proc/stat does not exist")?; -fn read_sysfs_value_as_u32(path: &str) -> Result { - if !Path::new(path).exists() { - return Err(ControlError::NotSupported(format!( - "File does not exist: {path}" - ))); - } + cache + .stat + .set(HashMap::from_iter(content.lines().skip(1).filter_map( + |line| { + let mut parts = line.strip_prefix("cpu")?.split_whitespace(); - let content = fs::read_to_string(path) - .map_err(|e| ControlError::ReadError(format!("Failed to read {path}: {e}")))?; + let number = parts.next()?.parse().ok()?; - content - .trim() - .parse::() - .map_err(|e| ControlError::ParseError(format!("Failed to parse value from {path}: {e}"))) -} + let stat = CpuStat { + user: parts.next()?.parse().ok()?, + nice: parts.next()?.parse().ok()?, + system: parts.next()?.parse().ok()?, + idle: parts.next()?.parse().ok()?, + iowait: parts.next()?.parse().ok()?, + irq: parts.next()?.parse().ok()?, + softirq: parts.next()?.parse().ok()?, + steal: parts.next()?.parse().ok()?, + }; -fn validate_min_frequency(core_id: u32, new_min_freq_mhz: u32) -> Result<()> { - let max_freq_path = format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/scaling_max_freq"); + Some((number, stat)) + }, + ))) + .unwrap(); - if !Path::new(&max_freq_path).exists() { - return Ok(()); - } - - let max_freq_khz = read_sysfs_value_as_u32(&max_freq_path)?; - let new_min_freq_khz = new_min_freq_mhz * 1000; - - if new_min_freq_khz > max_freq_khz { - return Err(ControlError::InvalidValueError(format!( - "Minimum frequency ({} MHz) cannot be higher than maximum frequency ({} MHz) for core {}", - new_min_freq_mhz, - max_freq_khz / 1000, - core_id - ))); - } - - Ok(()) -} - -fn validate_max_frequency(core_id: u32, new_max_freq_mhz: u32) -> Result<()> { - let min_freq_path = format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/scaling_min_freq"); - - if !Path::new(&min_freq_path).exists() { - return Ok(()); - } - - let min_freq_khz = read_sysfs_value_as_u32(&min_freq_path)?; - let new_max_freq_khz = new_max_freq_mhz * 1000; - - if new_max_freq_khz < min_freq_khz { - return Err(ControlError::InvalidValueError(format!( - "Maximum frequency ({} MHz) cannot be lower than minimum frequency ({} MHz) for core {}", - new_max_freq_mhz, - min_freq_khz / 1000, - core_id - ))); - } - - Ok(()) -} - -/// Sets the platform profile. -/// This changes the system performance, temperature, fan, and other hardware replated characteristics. -/// -/// Also see [`The Kernel docs`] for this. -/// -/// [`The Kernel docs`]: -/// -/// # Examples -/// -/// ``` -/// set_platform_profile("balanced"); -/// ``` -/// -pub fn set_platform_profile(profile: &str) -> Result<()> { - let path = "/sys/firmware/acpi/platform_profile"; - if !Path::new(path).exists() { - return Err(ControlError::NotSupported(format!( - "Platform profile control not found at {path}.", - ))); - } - - let available_profiles = get_platform_profiles()?; - - if !available_profiles.contains(&profile.to_string()) { - return Err(ControlError::InvalidProfile(format!( - "Invalid platform control profile provided.\n\ - Provided profile: {} \n\ - Available profiles:\n\ - {}", - profile, - available_profiles.join(", ") - ))); - } - write_sysfs_value(path, profile) -} - -/// Returns the list of available platform profiles. -/// -/// # Errors -/// -/// # Returns -/// -/// - [`ControlError::NotSupported`] if: -/// - The file `/sys/firmware/acpi/platform_profile_choices` does not exist. -/// - The file `/sys/firmware/acpi/platform_profile_choices` is empty. -/// -/// - [`ControlError::PermissionDenied`] if the file `/sys/firmware/acpi/platform_profile_choices` cannot be read. -/// -pub fn get_platform_profiles() -> Result> { - let path = "/sys/firmware/acpi/platform_profile_choices"; - - if !Path::new(path).exists() { - return Err(ControlError::NotSupported(format!( - "Platform profile choices not found at {path}." - ))); - } - - let content = fs::read_to_string(path) - .map_err(|_| ControlError::PermissionDenied(format!("Cannot read contents of {path}.")))?; - - Ok(content - .split_whitespace() - .map(ToString::to_string) - .collect()) -} - -/// Path for storing the governor override state -const GOVERNOR_OVERRIDE_PATH: &str = "/etc/xdg/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/xdg/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) + cache.stat.get().unwrap() } - })?; + }; + + self.stat = stat + .get(&self.number) + .with_context(|| format!("failed to get stat of {self}"))? + .clone(); + + Ok(()) } - 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) + fn rescan_temperature(&mut self, cache: &CpuRescanCache) -> anyhow::Result<()> { + // OnceCell::get_or_try_init is unstable. Cope: + let temperatures = match cache.temperatures.get() { + Some(temperature) => temperature, + + None => { + const PATH: &str = "/sys/class/hwmon"; + + let temperatures = HashMap::new(); + + for entry in fs::read_dir(PATH) + .with_context(|| format!("failed to read hardware information from '{PATH}'"))? + .with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))? + { + let entry = + entry.with_context(|| format!("failed to read entry of '{PATH}'"))?; + + let entry_path = entry.path(); + + let Some(name) = fs::read(entry_path.join("name")).with_context(|| { + format!( + "failed to read name of hardware entry at '{path}'", + path = entry_path.display(), + ) + })? + else { + continue; + }; + + match &*name { + // Intel CPU temperature driver + "coretemp" => todo!(), + + // AMD CPU temperature driver + // TODO: 'zenergy' can also report those stats, I think? + "k10temp" | "zenpower" | "amdgpu" => todo!(), + + // Other CPU temperature drivers + _ if name.contains("cpu") || name.contains("temp") => todo!(), + + _ => {} } - })?; - 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)?; + cache.temperatures.set(temperatures).unwrap(); + cache.temperatures.get().unwrap() + } + }; - println!( - "Governor override set to '{governor}'. This setting will persist across reboots." + self.temperature = temperatures.get(&self.number).copied(); + + Ok(()) + } + + pub fn set_governor(&mut self, governor: &str) -> anyhow::Result<()> { + let Self { + number, + available_governors: ref governors, + .. + } = *self; + + if !governors + .iter() + .any(|avail_governor| avail_governor == governor) + { + bail!( + "governor '{governor}' is not available for {self}. available governors: {governors}", + governors = governors.join(", "), ); - 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 + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_governor"), + governor, + ) + .with_context(|| { + format!( + "this probably means that {self} doesn't exist or doesn't support changing governors" + ) + })?; + + self.governor = Some(governor.to_owned()); + + Ok(()) + } + + pub fn set_epp(&mut self, epp: &str) -> anyhow::Result<()> { + let Self { + number, + available_epps: ref epps, + .. + } = *self; + + if !epps.iter().any(|avail_epp| avail_epp == epp) { + bail!( + "EPP value '{epp}' is not availabile for {self}. available EPP values: {epps}", + epps = epps.join(", "), + ); + } + + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_preference"), + epp, + ) + .with_context(|| { + format!("this probably means that {self} doesn't exist or doesn't support changing EPP") + })?; + + self.epp = Some(epp.to_owned()); + + Ok(()) + } + + pub fn set_epb(&mut self, epb: &str) -> anyhow::Result<()> { + let Self { + number, + available_epbs: ref epbs, + .. + } = *self; + + if !epbs.iter().any(|avail_epb| avail_epb == epb) { + bail!( + "EPB value '{epb}' is not available for {self}. available EPB values: {valid}", + valid = epbs.join(", "), + ); + } + + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/energy_performance_bias"), + epb, + ) + .with_context(|| { + format!("this probably means that {self} doesn't exist or doesn't support changing EPB") + })?; + + self.epb = Some(epb.to_owned()); + + Ok(()) + } + + pub fn set_frequency_mhz_minimum(&mut self, frequency_mhz: u64) -> anyhow::Result<()> { + let Self { number, .. } = *self; + + self.validate_frequency_mhz_minimum(frequency_mhz)?; + + // We use u64 for the intermediate calculation to prevent overflow + let frequency_khz = frequency_mhz * 1000; + let frequency_khz = frequency_khz.to_string(); + + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq"), + &frequency_khz, + ) + .with_context(|| { + format!("this probably means that {self} doesn't exist or doesn't support changing minimum frequency") + })?; + + self.frequency_mhz_minimum = Some(frequency_mhz); + + Ok(()) + } + + fn validate_frequency_mhz_minimum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { + let Self { number, .. } = self; + + let Some(minimum_frequency_khz) = fs::read_n::(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" + )) + .with_context(|| format!("failed to read {self} minimum frequency"))? + else { + // Just let it pass if we can't find anything. + return Ok(()); + }; + + 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, + ); + } + + Ok(()) + } + + pub fn set_frequency_mhz_maximum(&mut self, frequency_mhz: u64) -> anyhow::Result<()> { + let Self { number, .. } = *self; + + self.validate_frequency_mhz_maximum(frequency_mhz)?; + + // We use u64 for the intermediate calculation to prevent overflow + let frequency_khz = frequency_mhz * 1000; + let frequency_khz = frequency_khz.to_string(); + + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_max_freq"), + &frequency_khz, + ) + .with_context(|| { + format!("this probably means that {self} doesn't exist or doesn't support changing maximum frequency") + })?; + + self.frequency_mhz_maximum = Some(frequency_mhz); + + Ok(()) + } + + fn validate_frequency_mhz_maximum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { + let Self { number, .. } = self; + + let Some(maximum_frequency_khz) = fs::read_n::(format!( + "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_max_freq" + )) + .with_context(|| format!("failed to read {self} maximum frequency"))? + else { + // Just let it pass if we can't find anything. + return Ok(()); + }; + + if new_frequency_mhz * 1000 > maximum_frequency_khz { + bail!( + "new maximum frequency ({new_frequency_mhz} MHz) cannot be higher than the maximum frequency ({} MHz) for {self}", + maximum_frequency_khz / 1000, + ); + } + + Ok(()) + } + + pub fn set_turbo(on: bool) -> anyhow::Result<()> { + let value_boost = match on { + true => "1", // boost = 1 means turbo is enabled. + false => "0", // boost = 0 means turbo is disabled. + }; + + let value_boost_negated = match on { + true => "0", // no_turbo = 0 means turbo is enabled. + false => "1", // no_turbo = 1 means turbo is disabled. + }; + + // AMD specific paths + let amd_boost_path = "/sys/devices/system/cpu/amd_pstate/cpufreq/boost"; + let msr_boost_path = "/sys/devices/system/cpu/cpufreq/amd_pstate_enable_boost"; + + // Path priority (from most to least specific) + let intel_boost_path_negated = "/sys/devices/system/cpu/intel_pstate/no_turbo"; + 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() { + return Ok(()); + } + if fs::write(amd_boost_path, value_boost).is_ok() { + return Ok(()); + } + if fs::write(msr_boost_path, value_boost).is_ok() { + return Ok(()); + } + if fs::write(generic_boost_path, value_boost).is_ok() { + return Ok(()); + } + + // Also try per-core cpufreq boost for some AMD systems. + if Self::all()?.iter().any(|cpu| { + let Cpu { number, .. } = cpu; + + fs::write( + format!("/sys/devices/system/cpu/cpu{number}/cpufreq/boost"), + value_boost, + ) + .is_ok() + }) { + return Ok(()); + } + + bail!("no supported CPU boost control mechanism found"); + } + + pub fn turbo() -> anyhow::Result> { + if let Some(content) = fs::read_n::("/sys/devices/system/cpu/intel_pstate/no_turbo") + .context("failed to read CPU turbo boost status")? + { + return Ok(Some(content == 0)); + } + + if let Some(content) = fs::read_n::("/sys/devices/system/cpu/cpufreq/boost") + .context("failed to read CPU turbo boost status")? + { + return Ok(Some(content == 1)); + } + + Ok(None) } } diff --git a/src/daemon.rs b/src/daemon.rs index dd90884..f2d2e3a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,671 +1,262 @@ -use crate::config::{AppConfig, LogLevel}; -use crate::core::SystemReport; -use crate::engine; -use crate::monitor; -use crate::util::error::{AppError, ControlError}; -use log::{LevelFilter, debug, error, info, warn}; -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 std::{ + collections::VecDeque, + ops, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + 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, -} +use anyhow::Context; -/// Calculate the idle time multiplier based on system idle duration +use crate::config; + +/// 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, -) -> 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); - - 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, + + /// 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, } -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(); - 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(); - 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(); - 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(); - 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, - ) -> 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, + recent_average < 0.1 + && self + .cpu_volatility() + .is_none_or(|volatility| volatility.usage < 0.05) + } +} + +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) + } +} + +impl Daemon { + fn polling_interval(&mut self) -> Duration { + let mut interval = Duration::from_secs(5); + + // 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 { + // *= 1.5; + interval /= 2; + interval *= 3; + } + } + + // If we can't deterine the discharge rate, that means that + // we were very recently started. Which is user activity. + None => { + interval *= 2; + } + } + } + + 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)); + } + } + + 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, }; - compute_new(¶ms, self) + let interval = Duration::from_secs_f64(interval.as_secs_f64().clamp(1.0, 30.0)); + + self.last_polling_interval = Some(interval); + + interval } } -/// 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) -> Result<(), ControlError> { - if min_interval < 1 { - return Err(ControlError::InvalidValueError( - "min_interval must be ≥ 1".to_string(), - )); - } - if max_interval < 1 { - return Err(ControlError::InvalidValueError( - "max_interval must be ≥ 1".to_string(), - )); - } - if max_interval >= min_interval { - Ok(()) - } else { - Err(ControlError::InvalidValueError(format!( - "Invalid interval configuration: max_interval ({max_interval}) is less than min_interval ({min_interval})" - ))) - } -} +pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { + log::info!("starting daemon..."); -/// Run the daemon -pub fn run_daemon(config: AppConfig, verbose: bool) -> Result<(), AppError> { - // Set effective log level based on config and verbose flag - let effective_log_level = if verbose { - LogLevel::Debug - } else { - config.daemon.log_level - }; + let cancelled = Arc::new(AtomicBool::new(false)); - // Get the appropriate level filter - let level_filter = match effective_log_level { - LogLevel::Error => LevelFilter::Error, - LogLevel::Warning => LevelFilter::Warn, - LogLevel::Info => LevelFilter::Info, - LogLevel::Debug => LevelFilter::Debug, - }; - - // Update the log level filter if needed, without re-initializing the logger - log::set_max_level(level_filter); - - info!("Starting superfreq daemon..."); - - // Validate critical configuration values before proceeding - if let Err(err) = validate_poll_intervals( - config.daemon.min_poll_interval_sec, - config.daemon.max_poll_interval_sec, - ) { - return Err(AppError::Control(err)); - } - - // 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 + let cancelled_ = Arc::clone(&cancelled); ctrlc::set_handler(move || { - info!("Received shutdown signal, exiting..."); - r.store(false, Ordering::SeqCst); + log::info!("received shutdown signal"); + cancelled_.store(true, Ordering::SeqCst); }) - .map_err(|e| AppError::Generic(format!("Error setting Ctrl-C handler: {e}")))?; + .context("failed to set Ctrl-C handler")?; - info!( - "Daemon initialized with poll interval: {}s", - config.daemon.poll_interval_sec - ); + while !cancelled.load(Ordering::SeqCst) {} - // Set up stats file if configured - if let Some(stats_path) = &config.daemon.stats_file_path { - 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 { - 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) => { - 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) { - error!("Failed to write stats file: {e}"); - } - } - - match engine::determine_and_apply_settings(&report, &config, None) { - Ok(()) => { - debug!("Successfully applied system settings"); - - // If system state changed, log the new state - if system_history.current_state != previous_state { - info!( - "System state changed to: {:?}", - system_history.current_state - ); - } - } - Err(e) => { - 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); - - 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 - 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, - ); - - 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); - - 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 { - debug!("Using minimum poll interval of 1s instead of configured 0s"); - } - } - } - } - Err(e) => { - 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; - debug!("Sleeping for {}s until next cycle", sleep_time.as_secs()); - std::thread::sleep(sleep_time); - } - } - - 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)?; + log::info!("exiting..."); 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..3a20cb4 --- /dev/null +++ b/src/daemon_old.rs @@ -0,0 +1,426 @@ +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}; + +/// 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 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 + } +} + +/// 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(()) +} + +/// 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/engine.rs b/src/engine.rs index bbefc86..6c5fe59 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,9 +1,7 @@ -use crate::battery; use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings}; -use crate::core::{OperationalMode, SystemReport, TurboSetting}; +use crate::core::{OperationalMode, SystemReport}; use crate::cpu::{self}; -use crate::util::error::{ControlError, EngineError}; -use log::{debug, info, warn}; +use crate::power_supply; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -120,30 +118,14 @@ impl TurboHysteresis { /// 1. Try to apply a feature setting /// 2. If not supported, log a warning and continue /// 3. If other error, propagate the error -fn try_apply_feature( +fn try_apply_feature anyhow::Result<()>, T>( feature_name: &str, value_description: &str, apply_fn: F, -) -> Result<(), EngineError> -where - F: FnOnce() -> Result, -{ - info!("Setting {feature_name} to '{value_description}'"); +) -> anyhow::Result<()> { + log::info!("Setting {feature_name} to '{value_description}'"); - match apply_fn() { - Ok(_) => Ok(()), - Err(e) => { - if matches!(e, ControlError::NotSupported(_)) { - warn!( - "{feature_name} setting is not supported on this system. Skipping {feature_name} configuration." - ); - Ok(()) - } else { - // Propagate all other errors, including InvalidValueError - Err(EngineError::ControlError(e)) - } - } - } + apply_fn() } /// Determines the appropriate CPU profile based on power status or forced mode, @@ -152,19 +134,19 @@ pub fn determine_and_apply_settings( report: &SystemReport, 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() { - info!( - "Governor override is active: '{}'. Setting governor.", - override_governor.trim() - ); +) -> anyhow::Result<()> { + // // First, check if there's a governor override set + // if let Some(override_governor) = cpu::get_governor_override() { + // log::info!( + // "Governor override is active: '{}'. Setting governor.", + // override_governor.trim() + // ); - // Apply the override governor setting - try_apply_feature("override governor", override_governor.trim(), || { - cpu::set_governor(override_governor.trim(), None) - })?; - } + // // Apply the override governor setting + // try_apply_feature("override governor", override_governor.trim(), || { + // cpu::set_governor(override_governor.trim(), None) + // })?; + // } // Determine AC/Battery status once, early in the function // For desktops (no batteries), we should always use the AC power profile @@ -182,52 +164,46 @@ pub fn determine_and_apply_settings( if let Some(mode) = force_mode { match mode { OperationalMode::Powersave => { - info!("Forced Powersave mode selected. Applying 'battery' profile."); + log::info!("Forced Powersave mode selected. Applying 'battery' profile."); selected_profile_config = &config.battery; } OperationalMode::Performance => { - info!("Forced Performance mode selected. Applying 'charger' profile."); + log::info!("Forced Performance mode selected. Applying 'charger' profile."); selected_profile_config = &config.charger; } } } else { // Use the previously computed on_ac_power value if on_ac_power { - info!("On AC power, selecting Charger profile."); + log::info!("On AC power, selecting Charger profile."); selected_profile_config = &config.charger; } else { - info!("On Battery power, selecting Battery profile."); + log::info!("On Battery power, selecting Battery profile."); selected_profile_config = &config.battery; } } // Apply settings from selected_profile_config if let Some(governor) = &selected_profile_config.governor { - info!("Setting governor to '{governor}'"); - // Let set_governor handle the validation - if let Err(e) = cpu::set_governor(governor, None) { - // If the governor is not available, log a warning - if matches!(e, ControlError::InvalidGovernor(_)) - || matches!(e, ControlError::NotSupported(_)) - { - warn!( - "Configured governor '{governor}' is not available on this system. Skipping." - ); - } else { - return Err(e.into()); + log::info!("Setting governor to '{governor}'"); + for cpu in cpu::Cpu::all()? { + // Let set_governor handle the validation + if let Err(error) = cpu.set_governor(governor) { + // If the governor is not available, log a warning + log::warn!("{error}"); } } } if let Some(turbo_setting) = selected_profile_config.turbo { - info!("Setting turbo to '{turbo_setting:?}'"); + log::info!("Setting turbo to '{turbo_setting:?}'"); match turbo_setting { TurboSetting::Auto => { if selected_profile_config.enable_auto_turbo { - debug!("Managing turbo in auto mode based on system conditions"); + log::debug!("Managing turbo in auto mode based on system conditions"); manage_auto_turbo(report, selected_profile_config, on_ac_power)?; } else { - debug!( + log::debug!( "Superfreq's dynamic turbo management is disabled by configuration. Ensuring system uses its default behavior for automatic turbo control." ); // Make sure the system is set to its default automatic turbo mode. @@ -255,13 +231,13 @@ pub fn determine_and_apply_settings( if let Some(min_freq) = selected_profile_config.min_freq_mhz { try_apply_feature("min frequency", &format!("{min_freq} MHz"), || { - cpu::set_min_frequency(min_freq, None) + cpu::set_frequency_minimum(min_freq, None) })?; } if let Some(max_freq) = selected_profile_config.max_freq_mhz { try_apply_feature("max frequency", &format!("{max_freq} MHz"), || { - cpu::set_max_frequency(max_freq, None) + cpu::set_frequency_maximum(max_freq, None) })?; } @@ -277,19 +253,19 @@ pub fn determine_and_apply_settings( let stop_threshold = thresholds.stop; if start_threshold < stop_threshold && stop_threshold <= 100 { - info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%"); - match battery::set_battery_charge_thresholds(start_threshold, stop_threshold) { - Ok(()) => debug!("Battery charge thresholds set successfully"), - Err(e) => warn!("Failed to set battery charge thresholds: {e}"), + log::info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%"); + match power_supply::set_battery_charge_thresholds(start_threshold, stop_threshold) { + Ok(()) => log::debug!("Battery charge thresholds set successfully"), + Err(e) => log::warn!("Failed to set battery charge thresholds: {e}"), } } else { - warn!( + log::warn!( "Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}" ); } } - debug!("Profile settings applied successfully."); + log::debug!("Profile settings applied successfully."); Ok(()) } @@ -298,7 +274,7 @@ fn manage_auto_turbo( report: &SystemReport, config: &ProfileConfig, on_ac_power: bool, -) -> Result<(), EngineError> { +) -> anyhow::Result<()> { // Get the auto turbo settings from the config let turbo_settings = &config.turbo_auto_settings; @@ -346,27 +322,30 @@ fn manage_auto_turbo( let enable_turbo = match (cpu_temp, avg_cpu_usage, previous_turbo_enabled) { // If temperature is too high, disable turbo regardless of load (Some(temp), _, _) if temp >= turbo_settings.temp_threshold_high => { - info!( + log::info!( "Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)", - temp, turbo_settings.temp_threshold_high + 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 => { - info!( + log::info!( "Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)", - usage, turbo_settings.load_threshold_high + usage, + turbo_settings.load_threshold_high ); true } // If load is low, disable turbo (_, Some(usage), _) if usage <= turbo_settings.load_threshold_low => { - info!( + log::info!( "Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)", - usage, turbo_settings.load_threshold_low + usage, + turbo_settings.load_threshold_low ); false } @@ -376,7 +355,7 @@ fn manage_auto_turbo( if usage > turbo_settings.load_threshold_low && usage < turbo_settings.load_threshold_high => { - info!( + log::info!( "Auto Turbo: Maintaining previous state ({}) due to intermediate load ({:.1}%)", if prev_state { "enabled" } else { "disabled" }, usage @@ -386,7 +365,7 @@ fn manage_auto_turbo( // When CPU load data is present but temperature is missing, use the same hysteresis logic (None, Some(usage), prev_state) => { - info!( + log::info!( "Auto Turbo: Maintaining previous state ({}) due to missing temperature data (load: {:.1}%)", if prev_state { "enabled" } else { "disabled" }, usage @@ -396,7 +375,7 @@ fn manage_auto_turbo( // When all metrics are missing, maintain the previous state (None, None, prev_state) => { - info!( + log::info!( "Auto Turbo: Maintaining previous state ({}) due to missing all CPU metrics", if prev_state { "enabled" } else { "disabled" } ); @@ -405,7 +384,7 @@ fn manage_auto_turbo( // Any other cases with partial metrics, maintain previous state for stability (_, _, prev_state) => { - info!( + log::info!( "Auto Turbo: Maintaining previous state ({}) due to incomplete CPU metrics", if prev_state { "enabled" } else { "disabled" } ); @@ -429,7 +408,7 @@ fn manage_auto_turbo( TurboSetting::Never }; - info!( + log::info!( "Auto Turbo: Applying turbo change from {} to {}", if previous_turbo_enabled { "enabled" @@ -441,7 +420,7 @@ fn manage_auto_turbo( match cpu::set_turbo(turbo_setting) { Ok(()) => { - debug!( + log::debug!( "Auto Turbo: Successfully set turbo to {}", if enable_turbo { "enabled" } else { "disabled" } ); @@ -450,7 +429,7 @@ fn manage_auto_turbo( Err(e) => Err(EngineError::ControlError(e)), } } else { - debug!( + log::debug!( "Auto Turbo: Maintaining turbo state ({}) - no change needed", if enable_turbo { "enabled" } else { "disabled" } ); diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..3192e4d --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,65 @@ +use std::{error, fs, io, path::Path, str}; + +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(); + + match fs::read_dir(path) { + Ok(entries) => Ok(Some(entries)), + + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + + Err(error) => Err(error).context(format!( + "failed to read directory '{path}'", + path = path.display() + )), + } +} + +pub fn read(path: impl AsRef) -> anyhow::Result> { + let path = path.as_ref(); + + match fs::read_to_string(path) { + Ok(string) => Ok(Some(string.trim().to_owned())), + + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + + Err(error) => Err(error).context(format!("failed to read '{path}", path = path.display())), + } +} + +pub fn read_n(path: impl AsRef) -> anyhow::Result> +where + N::Err: error::Error + Send + Sync + 'static, +{ + let path = path.as_ref(); + + match read(path)? { + Some(content) => Ok(Some(content.trim().parse().with_context(|| { + format!( + "failed to parse contents of '{path}' as a unsigned number", + path = path.display(), + ) + })?)), + + None => Ok(None), + } +} + +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 edf762f..e435cee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,476 +1,121 @@ -mod battery; -mod cli; -mod config; -mod core; mod cpu; +mod power_supply; +mod system; + +mod fs; + +mod config; +// mod core; mod daemon; -mod engine; -mod monitor; -mod util; +// mod engine; +// mod monitor; -use crate::config::AppConfig; -use crate::core::{GovernorOverrideMode, TurboSetting}; -use crate::util::error::{AppError, ControlError}; -use clap::{Parser, value_parser}; -use env_logger::Builder; -use log::{debug, error, info}; -use std::error::Error; -use std::sync::Once; +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 _; -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] +#[derive(clap::Parser, Debug)] +#[clap(author, version, about)] struct Cli { + #[command(flatten)] + verbosity: clap_verbosity_flag::Verbosity, + #[clap(subcommand)] - command: Option, + command: Command, } -#[derive(Parser, Debug)] -enum Commands { - /// Display current system information +#[derive(clap::Parser, Debug)] +enum Command { + /// Display 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)] - setting: TurboSetting, - }, - /// Display comprehensive debug information - Debug, - /// Set Energy Performance Preference (EPP) - SetEpp { - epp: String, - #[clap(long)] - core_id: Option, - }, - /// Set Energy Performance Bias (EPB) - SetEpb { - epb: String, // Typically 0-15 - #[clap(long)] - core_id: Option, - }, - /// Set minimum CPU frequency - SetMinFreq { - freq_mhz: u32, - #[clap(long)] - core_id: Option, - }, - /// Set maximum CPU frequency - SetMaxFreq { - freq_mhz: u32, - #[clap(long)] - core_id: Option, - }, - /// Set ACPI platform profile - SetPlatformProfile { profile: String }, - /// Set battery charge thresholds to extend battery lifespan - SetBatteryThresholds { - /// Percentage at which charging starts (when below this value) - #[clap(value_parser = value_parser!(u8).range(0..=99))] - start_threshold: u8, - /// Percentage at which charging stops (when it reaches this value) - #[clap(value_parser = value_parser!(u8).range(1..=100))] - stop_threshold: u8, + + /// Start the daemon. + Start { + /// The daemon config path. + #[arg(long, env = "WATT_CONFIG")] + config: PathBuf, }, + + /// Modify CPU attributes. + CpuSet(config::CpuDelta), + + /// Modify power supply attributes. + PowerSet(config::PowerDelta), } -fn main() -> Result<(), AppError> { - // Initialize logger once for the entire application - init_logger(); - +fn real_main() -> anyhow::Result<()> { let cli = Cli::parse(); - // Load configuration first, as it might be needed by the monitor module - // E.g., for ignored power supplies - let config = match config::load_config() { - Ok(cfg) => cfg, - Err(e) => { - error!("Error loading configuration: {e}. Using default values."); - // Proceed with default config if loading fails - AppConfig::default() + yansi::whenever(yansi::Condition::TTY_AND_COLOR); + + env_logger::Builder::new() + .filter_level(cli.verbosity.log_level_filter()) + .format_timestamp(None) + .format_module_path(false) + .init(); + + match cli.command { + Command::Info => todo!(), + + Command::Start { config } => { + let config = config::DaemonConfig::load_from(&config) + .context("failed to load daemon config file")?; + + daemon::run(config) } + + Command::CpuSet(delta) => delta.apply(), + Command::PowerSet(delta) => delta.apply(), + } +} + +fn main() { + let Err(error) = real_main() else { + return; }; - let command_result: Result<(), AppError> = match cli.command { - // TODO: This will be moved to a different module in the future. - Some(Commands::Info) => match monitor::collect_system_report(&config) { - Ok(report) => { - // Format section headers with proper centering - let format_section = |title: &str| { - let title_len = title.len(); - let total_width = title_len + 8; // 8 is for padding (4 on each side) - let separator = "═".repeat(total_width); + let mut err = io::stderr(); - println!("\n╔{separator}╗"); + let mut message = String::new(); + let mut chain = error.chain().rev().peekable(); - // Calculate centering - println!("║ {title} ║"); - - println!("╚{separator}╝"); - }; - - format_section("System Information"); - println!("CPU Model: {}", report.system_info.cpu_model); - println!("Architecture: {}", report.system_info.architecture); - println!( - "Linux Distribution: {}", - report.system_info.linux_distribution - ); - - // Format timestamp in a readable way - println!("Current Time: {}", jiff::Timestamp::now()); - - format_section("CPU Global Info"); - println!( - "Current Governor: {}", - report - .cpu_global - .current_governor - .as_deref() - .unwrap_or("N/A") - ); - println!( - "Available Governors: {}", // 21 length baseline - report.cpu_global.available_governors.join(", ") - ); - println!( - "Turbo Status: {}", - match report.cpu_global.turbo_status { - Some(true) => "Enabled", - Some(false) => "Disabled", - None => "Unknown", - } - ); - - println!( - "EPP: {}", - report.cpu_global.epp.as_deref().unwrap_or("N/A") - ); - println!( - "EPB: {}", - report.cpu_global.epb.as_deref().unwrap_or("N/A") - ); - println!( - "Platform Profile: {}", - report - .cpu_global - .platform_profile - .as_deref() - .unwrap_or("N/A") - ); - println!( - "CPU Temperature: {}", - report.cpu_global.average_temperature_celsius.map_or_else( - || "N/A (No sensor detected)".to_string(), - |t| format!("{t:.1}°C") - ) - ); - - format_section("CPU Core Info"); - - // Get max core ID length for padding - let max_core_id_len = report - .cpu_cores - .last() - .map_or(1, |core| core.core_id.to_string().len()); - - // Table headers - println!( - " {:>width$} │ {:^10} │ {:^10} │ {:^10} │ {:^7} │ {:^9}", - "Core", - "Current", - "Min", - "Max", - "Usage", - "Temp", - width = max_core_id_len + 4 - ); - println!( - " {:─>width$}──┼─{:─^10}─┼─{:─^10}─┼─{:─^10}─┼─{:─^7}─┼─{:─^9}", - "", - "", - "", - "", - "", - "", - width = max_core_id_len + 4 - ); - - for core_info in &report.cpu_cores { - // Format frequencies: if current > max, show in a special way - let current_freq = match core_info.current_frequency_mhz { - Some(freq) => { - let max_freq = core_info.max_frequency_mhz.unwrap_or(0); - if freq > max_freq && max_freq > 0 { - // Special format for boosted frequencies - format!("{freq}*") - } else { - format!("{freq}") - } - } - None => "N/A".to_string(), - }; - - // CPU core display - println!( - " Core {:10} │ {:>10} │ {:>10} │ {:>7} │ {:>9}", - core_info.core_id, - format!("{} MHz", current_freq), - format!( - "{} MHz", - core_info - .min_frequency_mhz - .map_or_else(|| "N/A".to_string(), |f| f.to_string()) - ), - format!( - "{} MHz", - core_info - .max_frequency_mhz - .map_or_else(|| "N/A".to_string(), |f| f.to_string()) - ), - format!( - "{}%", - core_info - .usage_percent - .map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}")) - ), - format!( - "{}°C", - core_info - .temperature_celsius - .map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}")) - ), - width = max_core_id_len - ); - } - - // Only display battery info for systems that have real batteries - // Skip this section entirely on desktop systems - if !report.batteries.is_empty() { - let has_real_batteries = report.batteries.iter().any(|b| { - // Check if any battery has actual battery data - // (as opposed to peripherals like wireless mice) - b.capacity_percent.is_some() || b.power_rate_watts.is_some() - }); - - if has_real_batteries { - format_section("Battery Info"); - for battery_info in &report.batteries { - // Check if this appears to be a real system battery - if battery_info.capacity_percent.is_some() - || battery_info.power_rate_watts.is_some() - { - let power_status = if battery_info.ac_connected { - "Connected to AC" - } else { - "Running on Battery" - }; - - println!("Battery {}:", battery_info.name); - println!(" Power Status: {power_status}"); - println!( - " State: {}", - battery_info.charging_state.as_deref().unwrap_or("Unknown") - ); - - if let Some(capacity) = battery_info.capacity_percent { - println!(" Capacity: {capacity}%"); - } - - if let Some(power) = battery_info.power_rate_watts { - let direction = if power >= 0.0 { - "charging" - } else { - "discharging" - }; - println!( - " Power Rate: {:.2} W ({})", - power.abs(), - direction - ); - } - - // Display charge thresholds if available - if battery_info.charge_start_threshold.is_some() - || battery_info.charge_stop_threshold.is_some() - { - println!( - " Charge Thresholds: {}-{}", - battery_info - .charge_start_threshold - .map_or_else(|| "N/A".to_string(), |t| t.to_string()), - battery_info - .charge_stop_threshold - .map_or_else(|| "N/A".to_string(), |t| t.to_string()) - ); - } - } - } - } - } - - format_section("System Load"); - println!( - "Load Average (1m): {:.2}", - report.system_load.load_avg_1min - ); - println!( - "Load Average (5m): {:.2}", - report.system_load.load_avg_5min - ); - println!( - "Load Average (15m): {:.2}", - report.system_load.load_avg_15min - ); - Ok(()) - } - Err(e) => Err(AppError::Monitor(e)), - }, - Some(Commands::SetGovernor { governor, core_id }) => { - cpu::set_governor(&governor, core_id).map_err(AppError::Control) - } - Some(Commands::ForceGovernor { mode }) => { - cpu::force_governor(mode).map_err(AppError::Control) - } - Some(Commands::SetTurbo { setting }) => cpu::set_turbo(setting).map_err(AppError::Control), - Some(Commands::SetEpp { epp, core_id }) => { - cpu::set_epp(&epp, core_id).map_err(AppError::Control) - } - Some(Commands::SetEpb { epb, core_id }) => { - cpu::set_epb(&epb, core_id).map_err(AppError::Control) - } - Some(Commands::SetMinFreq { freq_mhz, core_id }) => { - // Basic validation for reasonable CPU frequency values - validate_freq(freq_mhz, "Minimum")?; - cpu::set_min_frequency(freq_mhz, core_id).map_err(AppError::Control) - } - Some(Commands::SetMaxFreq { freq_mhz, core_id }) => { - // Basic validation for reasonable CPU frequency values - validate_freq(freq_mhz, "Maximum")?; - cpu::set_max_frequency(freq_mhz, core_id).map_err(AppError::Control) - } - Some(Commands::SetPlatformProfile { profile }) => { - // Get available platform profiles and validate early if possible - match cpu::get_platform_profiles() { - Ok(available_profiles) => { - if available_profiles.contains(&profile) { - info!("Setting platform profile to '{profile}'"); - cpu::set_platform_profile(&profile).map_err(AppError::Control) - } else { - error!( - "Invalid platform profile: '{}'. Available profiles: {}", - profile, - available_profiles.join(", ") - ); - Err(AppError::Generic(format!( - "Invalid platform profile: '{}'. Available profiles: {}", - profile, - available_profiles.join(", ") - ))) - } - } - Err(_e) => { - // If we can't get profiles (e.g., feature not supported), pass through to the function - cpu::set_platform_profile(&profile).map_err(AppError::Control) - } - } - } - Some(Commands::SetBatteryThresholds { - start_threshold, - stop_threshold, - }) => { - // We only need to check if start < stop since the range validation is handled by Clap - if start_threshold >= stop_threshold { - error!( - "Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})" - ); - Err(AppError::Generic(format!( - "Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})" - ))) + while let Some(error) = chain.next() { + let _ = write!( + err, + "{header} ", + header = if chain.peek().is_none() { + "error:" } else { - info!( - "Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%" - ); - battery::set_battery_charge_thresholds(start_threshold, stop_threshold) - .map_err(AppError::Control) + "cause:" } - } - Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), - Some(Commands::Debug) => cli::debug::run_debug(&config), - None => { - info!("Welcome to superfreq! Use --help for commands."); - debug!("Current effective configuration: {config:?}"); - Ok(()) - } - }; + .red() + .bold(), + ); - if let Err(e) = command_result { - error!("Error executing command: {e}"); - if let Some(source) = e.source() { - error!("Caused by: {source}"); - } + String::clear(&mut message); + let _ = write!(message, "{error}"); - // Check for permission denied errors - if let AppError::Control(control_error) = &e { - if matches!(control_error, ControlError::PermissionDenied(_)) { - error!( - "Hint: This operation may require administrator privileges (e.g., run with sudo)." - ); + let mut chars = message.char_indices(); + + let _ = match (chars.next(), chars.next()) { + (Some((_, first)), Some((second_start, second))) if second.is_lowercase() => { + writeln!( + err, + "{first_lowercase}{rest}", + first_lowercase = first.to_lowercase(), + rest = &message[second_start..], + ) } - } - std::process::exit(1); + _ => { + writeln!(err, "{message}") + } + }; } - Ok(()) -} - -/// Initialize the logger for the entire application -static LOGGER_INIT: Once = Once::new(); -fn init_logger() { - LOGGER_INIT.call_once(|| { - // Set default log level based on environment or default to Info - let env_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); - - Builder::new() - .parse_filters(&env_log) - .format_timestamp(None) - .format_module_path(false) - .init(); - - debug!("Logger initialized with RUST_LOG={env_log}"); - }); -} - -/// Validate CPU frequency input values -fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), AppError> { - if freq_mhz == 0 { - error!("{label} frequency cannot be zero"); - Err(AppError::Generic(format!( - "{label} frequency cannot be zero" - ))) - } else if freq_mhz > 10000 { - // Extremely high value unlikely to be valid - error!("{label} frequency ({freq_mhz} MHz) is unreasonably high"); - Err(AppError::Generic(format!( - "{label} frequency ({freq_mhz} MHz) is unreasonably high" - ))) - } else { - Ok(()) - } + process::exit(1); } diff --git a/src/monitor.rs b/src/monitor.rs index 80605ff..d4534ba 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,8 +1,5 @@ use crate::config::AppConfig; use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport}; -use crate::cpu::get_logical_core_count; -use crate::util::error::SysMonitorError; -use log::debug; use std::{ collections::HashMap, fs, @@ -13,163 +10,13 @@ use std::{ time::SystemTime, }; -pub type Result = std::result::Result; - -// Read a sysfs file to a string, trimming whitespace -fn read_sysfs_file_trimmed(path: impl AsRef) -> Result { - fs::read_to_string(path.as_ref()) - .map(|s| s.trim().to_string()) - .map_err(|e| { - SysMonitorError::ReadError(format!("Path: {:?}, Error: {}", path.as_ref().display(), e)) - }) -} - -// Read a sysfs file and parse it to a specific type -fn read_sysfs_value(path: impl AsRef) -> Result { - let content = read_sysfs_file_trimmed(path.as_ref())?; - content.parse::().map_err(|_| { - SysMonitorError::ParseError(format!( - "Could not parse '{}' from {:?}", - content, - path.as_ref().display() - )) - }) -} - pub fn get_system_info() -> SystemInfo { let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string()); - let linux_distribution = get_linux_distribution().unwrap_or_else(|_| "Unknown".to_string()); - let architecture = std::env::consts::ARCH.to_string(); - SystemInfo { - cpu_model, - architecture, - linux_distribution, - } + SystemInfo { cpu_model } } -#[derive(Debug, Clone, Copy)] -pub struct CpuTimes { - user: u64, - nice: u64, - system: u64, - idle: u64, - iowait: u64, - irq: u64, - softirq: u64, - steal: u64, -} - -impl CpuTimes { - const fn total_time(&self) -> u64 { - self.user - + self.nice - + self.system - + self.idle - + self.iowait - + self.irq - + self.softirq - + self.steal - } - - const fn idle_time(&self) -> u64 { - self.idle + self.iowait - } -} - -fn read_all_cpu_times() -> Result> { - let content = fs::read_to_string("/proc/stat").map_err(SysMonitorError::Io)?; - let mut cpu_times_map = HashMap::new(); - - for line in content.lines() { - 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}" - ))); - } - - 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}" - )) - })?; - - let times = CpuTimes { - user: parts[1].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse user time: {}", - parts[1] - )) - })?, - nice: parts[2].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse nice time: {}", - parts[2] - )) - })?, - system: parts[3].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse system time: {}", - parts[3] - )) - })?, - idle: parts[4].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse idle time: {}", - parts[4] - )) - })?, - iowait: parts[5].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse iowait time: {}", - parts[5] - )) - })?, - irq: parts[6].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse irq time: {}", - parts[6] - )) - })?, - softirq: parts[7].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse softirq time: {}", - parts[7] - )) - })?, - steal: parts[8].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse steal time: {}", - parts[8] - )) - })?, - }; - cpu_times_map.insert(core_id, times); - } - } - Ok(cpu_times_map) -} - -pub fn get_cpu_core_info( - core_id: u32, - prev_times: &CpuTimes, - current_times: &CpuTimes, -) -> Result { - 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) - .ok(); - let min_frequency_mhz = read_sysfs_value::(cpufreq_path.join("scaling_min_freq")) - .map(|khz| khz / 1000) - .ok(); - let max_frequency_mhz = read_sysfs_value::(cpufreq_path.join("scaling_max_freq")) - .map(|khz| khz / 1000) - .ok(); - +pub fn get_cpu_core_info(core_id: u32) -> anyhow::Result { // Temperature detection. // Should be generic enough to be able to support for multiple hardware sensors // with the possibility of extending later down the road. @@ -260,31 +107,8 @@ pub fn get_cpu_core_info( } } - let usage_percent: Option = { - let prev_idle = prev_times.idle_time(); - let current_idle = current_times.idle_time(); - - let prev_total = prev_times.total_time(); - let current_total = current_times.total_time(); - - let total_diff = current_total.saturating_sub(prev_total); - let idle_diff = current_idle.saturating_sub(prev_idle); - - // Avoid division by zero if no time has passed or counters haven't changed - if total_diff == 0 { - None - } else { - let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32)); - Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100 - } - }; - Ok(CpuCoreInfo { core_id, - current_frequency_mhz, - min_frequency_mhz, - max_frequency_mhz, - usage_percent, temperature_celsius, }) } @@ -359,373 +183,7 @@ fn get_fallback_temperature(hw_path: &Path) -> Option { None } -pub fn get_all_cpu_core_info() -> Result> { - let initial_cpu_times = read_all_cpu_times()?; - thread::sleep(Duration::from_millis(250)); // interval for CPU usage calculation - let final_cpu_times = read_all_cpu_times()?; - - let num_cores = get_logical_core_count() - .map_err(|_| SysMonitorError::ReadError("Could not get the number of cores".to_string()))?; - - let mut core_infos = Vec::with_capacity(num_cores as usize); - - for core_id in 0..num_cores { - if let (Some(prev), Some(curr)) = ( - initial_cpu_times.get(&core_id), - final_cpu_times.get(&core_id), - ) { - match get_cpu_core_info(core_id, prev, curr) { - 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}"); - } - } - } else { - // Log or handle missing times for a core - eprintln!("Missing CPU time data for core {core_id}"); - } - } - Ok(core_infos) -} - -pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo { - // Find a valid CPU to read global settings from - // Try cpu0 first, then fall back to any available CPU with cpufreq - let mut cpufreq_base_path_buf = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/"); - - if !cpufreq_base_path_buf.exists() { - let core_count = get_logical_core_count().unwrap_or_else(|e| { - eprintln!("Warning: {e}"); - 0 - }); - - for i in 0..core_count { - let test_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/")); - if test_path.exists() { - cpufreq_base_path_buf = test_path; - break; // Exit the loop as soon as we find a valid path - } - } - } - - let turbo_status_path = Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo"); - let boost_path = Path::new("/sys/devices/system/cpu/cpufreq/boost"); - - let current_governor = if cpufreq_base_path_buf.join("scaling_governor").exists() { - read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_governor")).ok() - } else { - None - }; - - let available_governors = if cpufreq_base_path_buf - .join("scaling_available_governors") - .exists() - { - read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_available_governors")) - .map_or_else( - |_| vec![], - |s| s.split_whitespace().map(String::from).collect(), - ) - } else { - vec![] - }; - - let turbo_status = if turbo_status_path.exists() { - // 0 means turbo enabled, 1 means disabled for intel_pstate - read_sysfs_value::(turbo_status_path) - .map(|val| val == 0) - .ok() - } else if boost_path.exists() { - // 1 means turbo enabled, 0 means disabled for generic cpufreq boost - read_sysfs_value::(boost_path).map(|val| val == 1).ok() - } else { - None - }; - - // EPP (Energy Performance Preference) - let energy_perf_pref = - read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_preference")).ok(); - - // EPB (Energy Performance Bias) - let energy_perf_bias = - read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_bias")).ok(); - - let platform_profile = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile").ok(); - - // Calculate average CPU temperature from the core temperatures - 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() { - 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) - } - }; - - // Return the constructed CpuGlobalInfo - CpuGlobalInfo { - current_governor, - available_governors, - turbo_status, - epp: energy_perf_pref, - epb: energy_perf_bias, - platform_profile, - average_temperature_celsius, - } -} - -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() { - return Ok(batteries); // no power supply directory - } - - let ignored_supplies = config.ignored_power_supplies.clone().unwrap_or_default(); - - // Determine overall AC connection status - let mut overall_ac_connected = false; - for entry in fs::read_dir(power_supply_path)? { - let entry = entry?; - let ps_path = entry.path(); - let name = entry.file_name().into_string().unwrap_or_default(); - - // Check for AC adapter type (common names: AC, ACAD, ADP) - if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) { - if ps_type == "Mains" - || ps_type == "USB_PD_DRP" - || ps_type == "USB_PD" - || ps_type == "USB_DCP" - || ps_type == "USB_CDP" - || ps_type == "USB_ACA" - { - // USB types can also provide power - if let Ok(online) = read_sysfs_value::(ps_path.join("online")) { - if online == 1 { - overall_ac_connected = true; - break; - } - } - } - } else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") { - // Fallback for type file missing - if let Ok(online) = read_sysfs_value::(ps_path.join("online")) { - if online == 1 { - overall_ac_connected = true; - break; - } - } - } - } - - // No AC adapter detected but we're on a desktop system - // Default to AC power for desktops - if !overall_ac_connected { - overall_ac_connected = is_likely_desktop_system(); - } - - for entry in fs::read_dir(power_supply_path)? { - let entry = entry?; - let ps_path = entry.path(); - let name = entry.file_name().into_string().unwrap_or_default(); - - if ignored_supplies.contains(&name) { - continue; - } - - if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) { - if ps_type == "Battery" { - // Skip peripheral batteries that aren't real laptop batteries - if is_peripheral_battery(&ps_path, &name) { - debug!("Skipping peripheral battery: {name}"); - continue; - } - - let status_str = read_sysfs_file_trimmed(ps_path.join("status")).ok(); - let capacity_percent = read_sysfs_value::(ps_path.join("capacity")).ok(); - - let power_rate_watts = if ps_path.join("power_now").exists() { - read_sysfs_value::(ps_path.join("power_now")) // uW - .map(|uw| uw as f32 / 1_000_000.0) - .ok() - } else if ps_path.join("current_now").exists() - && ps_path.join("voltage_now").exists() - { - let current_ua = read_sysfs_value::(ps_path.join("current_now")).ok(); // uA - let voltage_uv = read_sysfs_value::(ps_path.join("voltage_now")).ok(); // uV - 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((f64::from(c) * f64::from(v) / 1_000_000_000_000.0) as f32) - } else { - None - } - } else { - None - }; - - let charge_start_threshold = - read_sysfs_value::(ps_path.join("charge_control_start_threshold")).ok(); - let charge_stop_threshold = - read_sysfs_value::(ps_path.join("charge_control_end_threshold")).ok(); - - batteries.push(BatteryInfo { - name: name.clone(), - ac_connected: overall_ac_connected, - charging_state: status_str, - capacity_percent, - power_rate_watts, - charge_start_threshold, - charge_stop_threshold, - }); - } - } - } - - // If we found no batteries but have power supplies, we're likely on a desktop - if batteries.is_empty() && overall_ac_connected { - debug!("No laptop batteries found, likely a desktop system"); - } - - Ok(batteries) -} - -/// Check if a battery is likely a peripheral (mouse, keyboard, etc) not a laptop battery -fn is_peripheral_battery(ps_path: &Path, name: &str) -> bool { - // Convert name to lowercase once for case-insensitive matching - let name_lower = name.to_lowercase(); - - // Common peripheral battery names - if name_lower.contains("mouse") - || name_lower.contains("keyboard") - || name_lower.contains("trackpad") - || name_lower.contains("gamepad") - || name_lower.contains("controller") - || name_lower.contains("headset") - || name_lower.contains("headphone") - { - return true; - } - - // Small capacity batteries are likely not laptop batteries - if let Ok(energy_full) = read_sysfs_value::(ps_path.join("energy_full")) { - // Most laptop batteries are at least 20,000,000 µWh (20 Wh) - // Peripheral batteries are typically much smaller - if energy_full < 10_000_000 { - // 10 Wh in µWh - return true; - } - } - - // Check for model name that indicates a peripheral - if let Ok(model) = read_sysfs_file_trimmed(ps_path.join("model_name")) { - if model.contains("bluetooth") || model.contains("wireless") { - return true; - } - } - - false -} - -/// Determine if this is likely a desktop system rather than a laptop -fn is_likely_desktop_system() -> bool { - // Check for DMI system type information - if let Ok(chassis_type) = fs::read_to_string("/sys/class/dmi/id/chassis_type") { - let chassis_type = chassis_type.trim(); - - // Chassis types: - // 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower - // 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One - // 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis - match chassis_type { - "3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => return true, // desktop form factors - "9" | "10" | "14" => return false, // laptop form factors - _ => {} // Unknown, continue with other checks - } - } - - // Check CPU power policies, desktops often don't have these - let power_saving_exists = Path::new("/sys/module/intel_pstate/parameters/no_hwp").exists() - || Path::new("/sys/devices/system/cpu/cpufreq/conservative").exists(); - - if !power_saving_exists { - return true; // likely a desktop - } - - // Check battery-specific ACPI paths that laptops typically have - let laptop_acpi_paths = [ - "/sys/class/power_supply/BAT0", - "/sys/class/power_supply/BAT1", - "/proc/acpi/battery", - ]; - - for path in &laptop_acpi_paths { - if Path::new(path).exists() { - return false; // Likely a laptop - } - } - - // Default to assuming desktop if we can't determine - true -} - -pub fn get_system_load() -> Result { - let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?; - let parts: Vec<&str> = loadavg_str.split_whitespace().collect(); - if parts.len() < 3 { - return Err(SysMonitorError::ParseError( - "Could not parse /proc/loadavg: expected at least 3 parts".to_string(), - )); - } - let load_avg_1min = parts[0].parse().map_err(|_| { - SysMonitorError::ParseError(format!("Failed to parse 1min load: {}", parts[0])) - })?; - let load_avg_5min = parts[1].parse().map_err(|_| { - SysMonitorError::ParseError(format!("Failed to parse 5min load: {}", parts[1])) - })?; - let load_avg_15min = parts[2].parse().map_err(|_| { - SysMonitorError::ParseError(format!("Failed to parse 15min load: {}", parts[2])) - })?; - - Ok(SystemLoad { - load_avg_1min, - load_avg_5min, - load_avg_15min, - }) -} - -pub fn collect_system_report(config: &AppConfig) -> Result { - let system_info = get_system_info(); - let cpu_cores = get_all_cpu_core_info()?; - let cpu_global = get_cpu_global_info(&cpu_cores); - let batteries = get_battery_info(config)?; - let system_load = get_system_load()?; - - Ok(SystemReport { - system_info, - cpu_cores, - cpu_global, - batteries, - system_load, - timestamp: SystemTime::now(), - }) -} - -pub fn get_cpu_model() -> Result { +pub fn get_cpu_model() -> anyhow::Result { let path = Path::new("/proc/cpuinfo"); let content = fs::read_to_string(path).map_err(|_| { SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display())) @@ -743,45 +201,3 @@ pub fn get_cpu_model() -> Result { "Could not find CPU model name in /proc/cpuinfo.".to_string(), )) } - -pub fn get_linux_distribution() -> Result { - let os_release_path = Path::new("/etc/os-release"); - let content = fs::read_to_string(os_release_path).map_err(|_| { - SysMonitorError::ReadError(format!( - "Cannot read contents of {}.", - os_release_path.display() - )) - })?; - - for line in content.lines() { - if line.starts_with("PRETTY_NAME=") { - if let Some(val) = line.split('=').nth(1) { - let linux_distribution = val.trim_matches('"').to_string(); - return Ok(linux_distribution); - } - } - } - - let lsb_release_path = Path::new("/etc/lsb-release"); - let content = fs::read_to_string(lsb_release_path).map_err(|_| { - SysMonitorError::ReadError(format!( - "Cannot read contents of {}.", - lsb_release_path.display() - )) - })?; - - for line in content.lines() { - if line.starts_with("DISTRIB_DESCRIPTION=") { - if let Some(val) = line.split('=').nth(1) { - let linux_distribution = val.trim_matches('"').to_string(); - return Ok(linux_distribution); - } - } - } - - Err(SysMonitorError::ParseError(format!( - "Could not find distribution name in {} or {}.", - os_release_path.display(), - lsb_release_path.display() - ))) -} diff --git a/src/power_supply.rs b/src/power_supply.rs new file mode 100644 index 0000000..2155c29 --- /dev/null +++ b/src/power_supply.rs @@ -0,0 +1,370 @@ +use anyhow::{Context, anyhow, bail}; +use yansi::Paint as _; + +use std::{ + fmt, + path::{Path, PathBuf}, +}; + +use crate::fs; + +/// Represents a pattern of path suffixes used to control charge thresholds +/// for different device vendors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PowerSupplyThresholdConfig { + pub manufacturer: &'static str, + pub path_start: &'static str, + pub path_end: &'static str, +} + +/// Power supply threshold configs. +const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[ + PowerSupplyThresholdConfig { + manufacturer: "Standard", + path_start: "charge_control_start_threshold", + path_end: "charge_control_end_threshold", + }, + PowerSupplyThresholdConfig { + manufacturer: "ASUS", + path_start: "charge_control_start_percentage", + path_end: "charge_control_end_percentage", + }, + // Combine Huawei and ThinkPad since they use identical paths. + PowerSupplyThresholdConfig { + manufacturer: "ThinkPad/Huawei", + path_start: "charge_start_threshold", + path_end: "charge_stop_threshold", + }, + // Framework laptop support. + PowerSupplyThresholdConfig { + manufacturer: "Framework", + path_start: "charge_behaviour_start_threshold", + path_end: "charge_behaviour_end_threshold", + }, +]; + +/// Represents a power supply that supports charge threshold control. +#[derive(Debug, Clone, PartialEq)] +pub struct PowerSupply { + pub name: String, + pub path: PathBuf, + + pub type_: String, + pub is_from_peripheral: bool, + + pub charge_state: Option, + pub charge_percent: Option, + + pub charge_threshold_start: f64, + pub charge_threshold_end: f64, + + pub drain_rate_watts: Option, + + pub threshold_config: Option, +} + +impl PowerSupply { + pub fn is_ac(&self) -> bool { + !self.is_from_peripheral + && matches!( + &*self.type_, + "Mains" | "USB_PD_DRP" | "USB_PD" | "USB_DCP" | "USB_CDP" | "USB_ACA" + ) + || self.type_.starts_with("AC") + || self.type_.contains("ACAD") + || self.type_.contains("ADP") + } +} + +impl fmt::Display for PowerSupply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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.green(), + )?; + } + + Ok(()) + } +} + +const POWER_SUPPLY_PATH: &str = "/sys/class/power_supply"; + +impl PowerSupply { + pub fn from_name(name: String) -> anyhow::Result { + let mut power_supply = Self { + path: Path::new(POWER_SUPPLY_PATH).join(&name), + name, + type_: String::new(), + + charge_state: None, + charge_percent: None, + + charge_threshold_start: 0.0, + charge_threshold_end: 1.0, + + drain_rate_watts: None, + + is_from_peripheral: false, + + threshold_config: None, + }; + + power_supply.rescan()?; + + Ok(power_supply) + } + + pub fn from_path(path: PathBuf) -> anyhow::Result { + let mut power_supply = PowerSupply { + name: path + .file_name() + .with_context(|| { + format!("failed to get file name of '{path}'", path = path.display(),) + })? + .to_string_lossy() + .to_string(), + + path, + type_: String::new(), + + charge_state: None, + charge_percent: None, + + charge_threshold_start: 0.0, + charge_threshold_end: 1.0, + + drain_rate_watts: None, + + is_from_peripheral: false, + + threshold_config: None, + }; + + power_supply.rescan()?; + + Ok(power_supply) + } + + pub fn all() -> anyhow::Result> { + let mut power_supplies = Vec::new(); + + for entry in fs::read_dir(POWER_SUPPLY_PATH) + .with_context(|| format!("failed to read '{POWER_SUPPLY_PATH}'"))? + .with_context(|| format!("'{POWER_SUPPLY_PATH}' doesn't exist, are you on linux?"))? + { + let entry = match entry { + Ok(entry) => entry, + + Err(error) => { + log::warn!("failed to read power supply entry: {error}"); + continue; + } + }; + + power_supplies.push(PowerSupply::from_path(entry.path())?); + } + + Ok(power_supplies) + } + + pub fn rescan(&mut self) -> anyhow::Result<()> { + if !self.path.exists() { + bail!("{self} does not exist"); + } + + self.type_ = { + let type_path = self.path.join("type"); + + fs::read(&type_path) + .with_context(|| format!("failed to read '{path}'", path = type_path.display()))? + .with_context(|| format!("'{path}' doesn't exist", path = type_path.display()))? + }; + + self.is_from_peripheral = 'is_from_peripheral: { + let name_lower = self.name.to_lowercase(); + + // Common peripheral battery names. + if name_lower.contains("mouse") + || name_lower.contains("keyboard") + || name_lower.contains("trackpad") + || name_lower.contains("gamepad") + || name_lower.contains("controller") + || name_lower.contains("headset") + || name_lower.contains("headphone") + { + break 'is_from_peripheral true; + } + + // Small capacity batteries are likely not laptop batteries. + if let Some(energy_full) = fs::read_n::(self.path.join("energy_full")) + .with_context(|| format!("failed to read the max charge {self} can hold"))? + { + // Most laptop batteries are at least 20,000,000 µWh (20 Wh). + // Peripheral batteries are typically much smaller. + if energy_full < 10_000_000 { + // 10 Wh in µWh. + break 'is_from_peripheral true; + } + } + // Check for model name that indicates a peripheral + if let Some(model) = fs::read(self.path.join("model_name")) + .with_context(|| format!("failed to read the model name of {self}"))? + { + if model.contains("bluetooth") || model.contains("wireless") { + break 'is_from_peripheral true; + } + } + + false + }; + + if self.type_ == "Battery" { + self.charge_state = fs::read(self.path.join("status")) + .with_context(|| format!("failed to read {self} charge status"))?; + + self.charge_percent = fs::read_n::(self.path.join("capacity")) + .with_context(|| format!("failed to read {self} charge percent"))? + .map(|percent| percent as f64 / 100.0); + + self.charge_threshold_start = + fs::read_n::(self.path.join("charge_control_start_threshold")) + .with_context(|| format!("failed to read {self} charge threshold start"))? + .map_or(0.0, |percent| percent as f64 / 100.0); + + self.charge_threshold_end = + fs::read_n::(self.path.join("charge_control_end_threshold")) + .with_context(|| format!("failed to read {self} charge threshold end"))? + .map_or(100.0, |percent| percent as f64 / 100.0); + + self.drain_rate_watts = match fs::read_n::(self.path.join("power_now")) + .with_context(|| format!("failed to read {self} power drain"))? + { + Some(drain) => Some(drain as f64), + + None => { + let current_ua = fs::read_n::(self.path.join("current_now")) + .with_context(|| format!("failed to read {self} current"))?; + + let voltage_uv = fs::read_n::(self.path.join("voltage_now")) + .with_context(|| format!("failed to read {self} voltage"))?; + + current_ua.zip(voltage_uv).map(|(current, voltage)| { + // Power (W) = Voltage (V) * Current (A) + // (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W + current as f64 * voltage as f64 / 1e12 + }) + } + }; + + self.threshold_config = POWER_SUPPLY_THRESHOLD_CONFIGS + .iter() + .find(|config| { + self.path.join(config.path_start).exists() + && self.path.join(config.path_end).exists() + }) + .copied(); + } + + Ok(()) + } + + pub fn charge_threshold_path_start(&self) -> Option { + self.threshold_config + .map(|config| self.path.join(config.path_start)) + } + + pub fn charge_threshold_path_end(&self) -> Option { + self.threshold_config + .map(|config| self.path.join(config.path_end)) + } + + pub fn set_charge_threshold_start( + &mut self, + charge_threshold_start: f64, + ) -> anyhow::Result<()> { + fs::write( + &self.charge_threshold_path_start().ok_or_else(|| { + anyhow!( + "power supply '{name}' does not support changing charge threshold levels", + name = self.name, + ) + })?, + &((charge_threshold_start * 100.0) as u8).to_string(), + ) + .with_context(|| format!("failed to set charge threshold start for {self}"))?; + + self.charge_threshold_start = charge_threshold_start; + + log::info!("set battery threshold start for {self} to {charge_threshold_start}%"); + + Ok(()) + } + + pub fn set_charge_threshold_end(&mut self, charge_threshold_end: f64) -> anyhow::Result<()> { + fs::write( + &self.charge_threshold_path_end().ok_or_else(|| { + anyhow!( + "power supply '{name}' does not support changing charge threshold levels", + name = self.name, + ) + })?, + &((charge_threshold_end * 100.0) as u8).to_string(), + ) + .with_context(|| format!("failed to set charge threshold end for {self}"))?; + + self.charge_threshold_end = charge_threshold_end; + + log::info!("set battery threshold end for {self} to {charge_threshold_end}%"); + + Ok(()) + } + + pub fn get_available_platform_profiles() -> anyhow::Result> { + let path = "/sys/firmware/acpi/platform_profile_choices"; + + let Some(content) = + fs::read(path).context("failed to read available ACPI platform profiles")? + else { + return Ok(Vec::new()); + }; + + Ok(content + .split_whitespace() + .map(ToString::to_string) + .collect()) + } + + /// Sets the platform profile. + /// This changes the system performance, temperature, fan, and other hardware replated characteristics. + /// + /// Also see [`The Kernel docs`] for this. + /// + /// [`The Kernel docs`]: + pub fn set_platform_profile(profile: &str) -> anyhow::Result<()> { + let profiles = Self::get_available_platform_profiles()?; + + if !profiles + .iter() + .any(|avail_profile| avail_profile == profile) + { + bail!( + "profile '{profile}' is not available for system. valid profiles: {profiles}", + profiles = profiles.join(", "), + ); + } + + fs::write("/sys/firmware/acpi/platform_profile", profile) + .context("this probably means that your system does not support changing ACPI profiles") + } + + pub fn platform_profile() -> anyhow::Result { + fs::read("/sys/firmware/acpi/platform_profile") + .context("failed to read platform profile")? + .context("failed to find platform profile") + } +} diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..57d5ce2 --- /dev/null +++ b/src/system.rs @@ -0,0 +1,127 @@ +use anyhow::{Context, bail}; + +use crate::{cpu, fs, power_supply}; + +pub struct System { + pub is_ac: bool, + + pub load_average_1min: f64, + pub load_average_5min: f64, + pub load_average_15min: f64, + + pub cpus: Vec, + pub power_supplies: Vec, +} + +impl System { + pub fn new() -> anyhow::Result { + let mut system = Self { + is_ac: false, + + cpus: Vec::new(), + power_supplies: Vec::new(), + + load_average_1min: 0.0, + load_average_5min: 0.0, + load_average_15min: 0.0, + }; + + system.rescan()?; + + Ok(system) + } + + pub fn rescan(&mut self) -> anyhow::Result<()> { + self.cpus = cpu::Cpu::all().context("failed to scan CPUs")?; + + self.power_supplies = + power_supply::PowerSupply::all().context("failed to scan power supplies")?; + + self.is_ac = self + .power_supplies + .iter() + .any(|power_supply| power_supply.is_ac()) + || self.is_desktop()?; + + self.rescan_load_average()?; + + Ok(()) + } + + fn is_desktop(&mut self) -> anyhow::Result { + if let Some(chassis_type) = + fs::read("/sys/class/dmi/id/chassis_type").context("failed to read chassis type")? + { + // 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower, + // 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One, + // 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis, + // 31=Convertible Laptop + match chassis_type.trim() { + // Desktop form factors. + "3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => { + return Ok(true); + } + + // Laptop form factors. + "9" | "10" | "14" | "31" => { + return Ok(false); + } + + // Unknown, continue with other checks + _ => {} + } + } + + // Check battery-specific ACPI paths that laptops typically have + let laptop_acpi_paths = [ + "/sys/class/power_supply/BAT0", + "/sys/class/power_supply/BAT1", + "/proc/acpi/battery", + ]; + + for path in laptop_acpi_paths { + if fs::exists(path) { + return Ok(false); // Likely a laptop. + } + } + + // Check CPU power policies, desktops often don't have these + let power_saving_exists = fs::exists("/sys/module/intel_pstate/parameters/no_hwp") + || fs::exists("/sys/devices/system/cpu/cpufreq/conservative"); + + if !power_saving_exists { + return Ok(true); // Likely a desktop. + } + + // Default to assuming desktop if we can't determine. + Ok(true) + } + + fn rescan_load_average(&mut self) -> anyhow::Result<()> { + let content = fs::read("/proc/loadavg") + .context("failed to read load average from '/proc/loadavg'")? + .context("'/proc/loadavg' doesn't exist, are you on linux?")?; + + let mut parts = content.split_whitespace(); + + let (Some(load_average_1min), Some(load_average_5min), Some(load_average_15min)) = + (parts.next(), parts.next(), parts.next()) + else { + bail!( + "failed to parse first 3 load average entries due to there not being enough, content: {content}" + ); + }; + + self.load_average_1min = load_average_1min + .parse() + .context("failed to parse load average")?; + self.load_average_5min = load_average_5min + .parse() + .context("failed to parse load average")?; + self.load_average_15min = load_average_15min + .parse() + .context("failed to parse load average")?; + + Ok(()) + } +} diff --git a/src/util/error.rs b/src/util/error.rs deleted file mode 100644 index b91081f..0000000 --- a/src/util/error.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::io; - -#[derive(Debug, thiserror::Error)] -pub enum ControlError { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - - #[error("Failed to write to sysfs path: {0}")] - WriteError(String), - - #[error("Failed to read sysfs path: {0}")] - ReadError(String), - - #[error("Invalid value for setting: {0}")] - InvalidValueError(String), - - #[error("Control action not supported: {0}")] - NotSupported(String), - - #[error("Permission denied: {0}. Try running with sudo.")] - PermissionDenied(String), - - #[error("Invalid platform control profile {0} supplied, please provide a valid one.")] - InvalidProfile(String), - - #[error("Invalid governor: {0}")] - InvalidGovernor(String), - - #[error("Failed to parse value: {0}")] - ParseError(String), - - #[error("Path missing: {0}")] - PathMissing(String), -} - -#[derive(Debug, thiserror::Error)] -pub enum SysMonitorError { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - - #[error("Failed to read sysfs path: {0}")] - ReadError(String), - - #[error("Failed to parse value: {0}")] - ParseError(String), - - #[error("Failed to parse /proc/stat: {0}")] - ProcStatParseError(String), -} - -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("CPU control error: {0}")] - ControlError(#[from] ControlError), - - #[error("Configuration error: {0}")] - ConfigurationError(String), -} - -// A unified error type for the entire application -#[derive(Debug, thiserror::Error)] -pub enum AppError { - #[error("{0}")] - Control(#[from] ControlError), - - #[error("{0}")] - Monitor(#[from] SysMonitorError), - - #[error("{0}")] - Engine(#[from] EngineError), - - #[error("{0}")] - Config(#[from] crate::config::ConfigError), - - #[error("{0}")] - Generic(String), - - #[error("I/O error: {0}")] - Io(#[from] io::Error), -} diff --git a/src/util/mod.rs b/src/util/mod.rs deleted file mode 100644 index 0aa2927..0000000 --- a/src/util/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod error; -pub mod sysfs; diff --git a/src/util/sysfs.rs b/src/util/sysfs.rs deleted file mode 100644 index e1776e5..0000000 --- a/src/util/sysfs.rs +++ /dev/null @@ -1,80 +0,0 @@ -use crate::util::error::ControlError; -use std::{fs, io, path::Path}; - -/// Write a value to a sysfs file with consistent error handling -/// -/// # Arguments -/// -/// * `path` - The file path to write to -/// * `value` - The string value to write -/// -/// # Errors -/// -/// Returns a `ControlError` variant based on the specific error: -/// - `ControlError::PermissionDenied` if permission is denied -/// - `ControlError::PathMissing` if the path doesn't exist -/// - `ControlError::WriteError` for other I/O errors -pub fn write_sysfs_value(path: impl AsRef, value: &str) -> Result<(), ControlError> { - let p = path.as_ref(); - - fs::write(p, value).map_err(|e| { - let error_msg = format!("Path: {:?}, Value: '{}', Error: {}", p.display(), value, e); - match e.kind() { - io::ErrorKind::PermissionDenied => ControlError::PermissionDenied(error_msg), - io::ErrorKind::NotFound => { - ControlError::PathMissing(format!("Path '{}' does not exist", p.display())) - } - _ => ControlError::WriteError(error_msg), - } - }) -} - -/// Read a value from a sysfs file with consistent error handling -/// -/// # Arguments -/// -/// * `path` - The file path to read from -/// -/// # Returns -/// -/// Returns the trimmed contents of the file as a String -/// -/// # Errors -/// -/// Returns a `ControlError` variant based on the specific error: -/// - `ControlError::PermissionDenied` if permission is denied -/// - `ControlError::PathMissing` if the path doesn't exist -/// - `ControlError::ReadError` for other I/O errors -pub fn read_sysfs_value(path: impl AsRef) -> Result { - let p = path.as_ref(); - fs::read_to_string(p) - .map_err(|e| { - let error_msg = format!("Path: {:?}, Error: {}", p.display(), e); - match e.kind() { - io::ErrorKind::PermissionDenied => ControlError::PermissionDenied(error_msg), - io::ErrorKind::NotFound => { - ControlError::PathMissing(format!("Path '{}' does not exist", p.display())) - } - _ => ControlError::ReadError(error_msg), - } - }) - .map(|s| s.trim().to_string()) -} - -/// Safely check if a path exists and is writable -/// -/// # Arguments -/// -/// * `path` - The file path to check -/// -/// # Returns -/// -/// Returns true if the path exists and is writable, false otherwise -pub fn path_exists_and_writable(path: &Path) -> bool { - if !path.exists() { - return false; - } - - // Try to open the file with write access to verify write permission - fs::OpenOptions::new().write(true).open(path).is_ok() -}