diff --git a/README.md b/README.md index 5669106..eb110c8 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,26 @@ sudo superfreq set-min-freq 1200 --core-id 0 sudo superfreq set-max-freq 2800 --core-id 1 ``` +### Battery Management + +```bash +# Set battery charging thresholds to extend battery lifespan +sudo superfreq set-battery-thresholds 40 80 # Start charging at 40%, stop at 80% +``` + +Battery charging thresholds help extend battery longevity by preventing constant +charging to 100%. Different laptop vendors implement this feature differently, +but Superfreq attempts to support multiple vendor implementations including: + +- Lenovo ThinkPad/IdeaPad (Standard implementation) +- ASUS laptops +- Huawei laptops +- Other devices using the standard Linux power_supply API + +Note that battery management is sensitive, and that your mileage may vary. +Please open an issue if your vendor is not supported, but patches would help +more than issue reports, as supporting hardware _needs_ hardware. + ## Configuration Superfreq uses TOML configuration files. Default locations: @@ -139,6 +159,8 @@ platform_profile = "performance" # Min/max frequency in MHz (optional) min_freq_mhz = 800 max_freq_mhz = 3500 +# Optional: Profile-specific battery charge thresholds (overrides global setting) +# battery_charge_thresholds = [40, 80] # Start at 40%, stop at 80% # Settings for when on battery power [battery] @@ -149,6 +171,13 @@ epb = "balance_power" platform_profile = "low-power" min_freq_mhz = 800 max_freq_mhz = 2500 +# Optional: Profile-specific battery charge thresholds (overrides global setting) +# battery_charge_thresholds = [60, 80] # Start at 60%, stop at 80% (more conservative) + +# Global battery charging thresholds (applied to both profiles unless overridden) +# Start charging at 40%, stop at 80% - extends battery lifespan +# NOTE: Profile-specific thresholds (in [charger] or [battery] sections) take precedence over this global setting +battery_charge_thresholds = [40, 80] # Daemon configuration [daemon] diff --git a/src/battery.rs b/src/battery.rs new file mode 100644 index 0000000..9beb05e --- /dev/null +++ b/src/battery.rs @@ -0,0 +1,262 @@ +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::ValidationError(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 index 86371cf..1011463 100644 --- a/src/cli/debug.rs +++ b/src/cli/debug.rs @@ -5,7 +5,7 @@ use crate::monitor; use std::error::Error; use std::fs; use std::process::{Command, Stdio}; -use std::time::{Duration, SystemTime}; +use std::time::Duration; /// Prints comprehensive debug information about the system pub fn run_debug(config: &AppConfig) -> Result<(), Box> { @@ -13,7 +13,6 @@ pub fn run_debug(config: &AppConfig) -> Result<(), Box> { println!("Version: {}", env!("CARGO_PKG_VERSION")); // Current date and time - let now = SystemTime::now(); let formatted_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); println!("Timestamp: {formatted_time}"); diff --git a/src/config/load.rs b/src/config/load.rs index 30deabe..1393a88 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -68,9 +68,7 @@ pub fn load_config_from_path(specific_path: Option<&str>) -> Result Result { let toml_app_config = toml::from_str::(&contents).map_err(ConfigError::TomlError)?; + // 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(toml_app_config.charger), - battery: ProfileConfig::from(toml_app_config.battery), - battery_charge_thresholds: toml_app_config.battery_charge_thresholds, + charger: ProfileConfig::from(charger_profile), + battery: ProfileConfig::from(battery_profile), ignored_power_supplies: toml_app_config.ignored_power_supplies, - poll_interval_sec: toml_app_config.poll_interval_sec, daemon: DaemonConfig { poll_interval_sec: toml_app_config.daemon.poll_interval_sec, adaptive_interval: toml_app_config.daemon.adaptive_interval, diff --git a/src/config/mod.rs b/src/config/mod.rs index b386b52..0a20a83 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,6 @@ +pub mod load; +pub mod types; pub mod watcher; -// Re-export all configuration types and functions -pub use self::load::*; -pub use self::types::*; - -// Internal organization of config submodules -mod load; -mod types; +pub use load::*; +pub use types::*; diff --git a/src/config/types.rs b/src/config/types.rs index 3f4f8ed..10e0a9c 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,9 +1,47 @@ // Configuration types and structures for superfreq use crate::core::TurboSetting; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::convert::TryFrom; + +#[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::ValidationError( + "Stop threshold must be greater than 0%".to_string(), + )); + } + if start >= stop { + return Err(ConfigError::ValidationError(format!( + "Start threshold ({start}) must be less than stop threshold ({stop})" + ))); + } + if stop > 100 { + return Err(ConfigError::ValidationError(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, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct ProfileConfig { pub governor: Option, pub turbo: Option, @@ -13,6 +51,8 @@ pub struct ProfileConfig { pub max_freq_mhz: Option, pub platform_profile: Option, pub turbo_auto_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery_charge_thresholds: Option, } impl Default for ProfileConfig { @@ -26,28 +66,22 @@ impl Default for ProfileConfig { max_freq_mhz: None, // no override platform_profile: None, // no override turbo_auto_settings: Some(TurboAutoSettings::default()), + battery_charge_thresholds: None, } } } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] pub struct AppConfig { #[serde(default)] pub charger: ProfileConfig, #[serde(default)] pub battery: ProfileConfig, - pub battery_charge_thresholds: Option<(u8, u8)>, // (start_threshold, stop_threshold) pub ignored_power_supplies: Option>, - #[serde(default = "default_poll_interval_sec")] - pub poll_interval_sec: u64, #[serde(default)] pub daemon: DaemonConfig, } -const fn default_poll_interval_sec() -> u64 { - 5 -} - // Error type for config loading #[derive(Debug)] pub enum ConfigError { @@ -55,6 +89,7 @@ pub enum ConfigError { TomlError(toml::de::Error), NoValidConfigFound, HomeDirNotFound, + ValidationError(String), } impl From for ConfigError { @@ -76,6 +111,7 @@ impl std::fmt::Display for ConfigError { Self::TomlError(e) => write!(f, "TOML parsing error: {e}"), Self::NoValidConfigFound => write!(f, "No valid configuration file found."), Self::HomeDirNotFound => write!(f, "Could not determine user home directory."), + Self::ValidationError(s) => write!(f, "Configuration validation error: {s}"), } } } @@ -83,7 +119,7 @@ impl std::fmt::Display for ConfigError { impl std::error::Error for ConfigError {} // Intermediate structs for TOML parsing -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct ProfileConfigToml { pub governor: Option, pub turbo: Option, // "always", "auto", "never" @@ -92,18 +128,19 @@ pub struct ProfileConfigToml { pub min_freq_mhz: Option, pub max_freq_mhz: Option, pub platform_profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery_charge_thresholds: Option, } -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Deserialize, Serialize, Debug, Clone, Default)] pub struct AppConfigToml { #[serde(default)] pub charger: ProfileConfigToml, #[serde(default)] pub battery: ProfileConfigToml, - pub battery_charge_thresholds: Option<(u8, u8)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery_charge_thresholds: Option, pub ignored_power_supplies: Option>, - #[serde(default = "default_poll_interval_sec")] - pub poll_interval_sec: u64, #[serde(default)] pub daemon: DaemonConfigToml, } @@ -118,11 +155,12 @@ impl Default for ProfileConfigToml { min_freq_mhz: None, max_freq_mhz: None, platform_profile: None, + battery_charge_thresholds: None, } } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct TurboAutoSettings { #[serde(default = "default_load_threshold_high")] pub load_threshold_high: f32, @@ -175,11 +213,12 @@ impl From for ProfileConfig { max_freq_mhz: toml_config.max_freq_mhz, platform_profile: toml_config.platform_profile, turbo_auto_settings: Some(TurboAutoSettings::default()), + battery_charge_thresholds: toml_config.battery_charge_thresholds, } } } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct DaemonConfig { #[serde(default = "default_poll_interval_sec")] pub poll_interval_sec: u64, @@ -197,7 +236,7 @@ pub struct DaemonConfig { pub stats_file_path: Option, } -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum LogLevel { Error, Warning, @@ -219,6 +258,10 @@ impl Default for DaemonConfig { } } +const fn default_poll_interval_sec() -> u64 { + 5 +} + const fn default_adaptive_interval() -> bool { false } @@ -243,7 +286,7 @@ const fn default_stats_file_path() -> Option { None } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct DaemonConfigToml { #[serde(default = "default_poll_interval_sec")] pub poll_interval_sec: u64, diff --git a/src/core.rs b/src/core.rs index 2be64ff..76dc940 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,8 +1,8 @@ use clap::ValueEnum; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] +#[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 diff --git a/src/cpu.rs b/src/cpu.rs index f50e737..eeb4dfa 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,11 +1,31 @@ use crate::core::{GovernorOverrideMode, TurboSetting}; use crate::util::error::ControlError; use core::str; -use std::path::PathBuf; use std::{fs, io, path::Path, string::ToString}; pub type Result = std::result::Result; +// Valid EPB string values +const VALID_EPB_STRINGS: &[&str] = &[ + "performance", + "balance-performance", + "balance_performance", // alternative form + "balance-power", + "balance_power", // alternative form + "power", +]; + +// 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(); @@ -83,15 +103,13 @@ where } pub fn set_governor(governor: &str, core_id: Option) -> Result<()> { - // First, check if the requested governor is available on the system - let available_governors = get_available_governors()?; + // 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)?; - if !available_governors - .iter() - .any(|g| g.eq_ignore_ascii_case(governor)) - { - return Err(ControlError::InvalidGovernor(format!( - "Governor '{}' is not available. Available governors: {}", + if !is_valid { + return Err(ControlError::InvalidValueError(format!( + "Governor '{}' is not available on this system. Valid governors: {}", governor, available_governors.join(", ") ))); @@ -111,53 +129,87 @@ pub fn set_governor(governor: &str, core_id: Option) -> Result<()> { core_id.map_or_else(|| for_each_cpu_core(action), action) } -/// Retrieves the list of available CPU governors on the system -pub fn get_available_governors() -> Result> { - // Prefer cpu0, fall back to first cpu with cpufreq - let mut governor_path = - PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors"); - if !governor_path.exists() { - let core_count = get_logical_core_count()?; - let candidate = (0..core_count) - .map(|i| format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_available_governors")) - .find(|path| Path::new(path).exists()); - if let Some(path) = candidate { - governor_path = path.into(); +/// 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 !governor_path.exists() { - return Err(ControlError::NotSupported( - "Could not determine available governors".to_string(), - )); - } - let content = fs::read_to_string(&governor_path).map_err(|e| { - if e.kind() == io::ErrorKind::PermissionDenied { - ControlError::PermissionDenied(format!( - "Permission denied reading from {}", - governor_path.display() - )) - } else { - ControlError::ReadError(format!( - "Failed to read from {}: {e}", - governor_path.display() - )) + // 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, + }; + + // Skip non-CPU directories + if !name.starts_with("cpu") + || name.len() <= 3 + || !name[3..].chars().all(char::is_numeric) + { + 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(); + + if !governors.is_empty() { + return Ok(governors); + } + } + Err(_) => continue, // try next CPU if this one fails + } + } } - })?; - - // Parse the space-separated list of governors - let governors = content - .split_whitespace() - .map(ToString::to_string) - .collect::>(); - - if governors.is_empty() { - return Err(ControlError::ParseError( - "No available governors found".to_string(), - )); } - Ok(governors) + // 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(), + )) } pub fn set_turbo(setting: TurboSetting) -> Result<()> { @@ -220,6 +272,16 @@ fn try_set_per_core_boost(value: &str) -> Result { } 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() { @@ -231,9 +293,31 @@ pub fn set_epp(epp: &str, core_id: Option) -> Result<()> { 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<()> { - // EPB is often an integer 0-15. Ensure `epb` string is valid if parsing. - // For now, writing it directly as a string. + // 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() { @@ -245,8 +329,50 @@ pub fn set_epb(epb: &str, core_id: Option) -> Result<()> { 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<()> { - let freq_khz_str = (freq_mhz * 1000).to_string(); + // 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(); + let action = |id: u32| { let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_min_freq"); if Path::new(&path).exists() { @@ -259,7 +385,21 @@ pub fn set_min_frequency(freq_mhz: u32, core_id: Option) -> Result<()> { } pub fn set_max_frequency(freq_mhz: u32, core_id: Option) -> Result<()> { - let freq_khz_str = (freq_mhz * 1000).to_string(); + // 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)?; + } + } + + // XXX: Use a u64 here as well. + let freq_khz = u64::from(freq_mhz) * 1000; + let freq_khz_str = freq_khz.to_string(); + let action = |id: u32| { let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_max_freq"); if Path::new(&path).exists() { @@ -271,6 +411,66 @@ pub fn set_max_frequency(freq_mhz: u32, core_id: Option) -> Result<()> { core_id.map_or_else(|| for_each_cpu_core(action), action) } +fn read_sysfs_value_as_u32(path: &str) -> Result { + if !Path::new(path).exists() { + return Err(ControlError::NotSupported(format!( + "File does not exist: {path}" + ))); + } + + let content = fs::read_to_string(path) + .map_err(|e| ControlError::ReadError(format!("Failed to read {path}: {e}")))?; + + content + .trim() + .parse::() + .map_err(|e| ControlError::ReadError(format!("Failed to parse value from {path}: {e}"))) +} + +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"); + + 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. /// @@ -311,11 +511,13 @@ pub fn set_platform_profile(profile: &str) -> Result<()> { /// /// # 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. +/// # Returns /// -/// Returns [`ControlError::PermissionDenied`] if the file `/sys/firmware/acpi/platform_profile_choices` cannot be read. +/// - [`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"; @@ -336,7 +538,7 @@ pub fn get_platform_profiles() -> Result> { } /// Path for storing the governor override state -const GOVERNOR_OVERRIDE_PATH: &str = "/etc/superfreq/governor_override"; +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<()> { diff --git a/src/daemon.rs b/src/daemon.rs index fc94800..edabb29 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -202,16 +202,6 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box LevelFilter { - match log_level { - LogLevel::Error => LevelFilter::Error, - LogLevel::Warning => LevelFilter::Warn, - LogLevel::Info => LevelFilter::Info, - LogLevel::Debug => LevelFilter::Debug, - } -} - /// 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)?; diff --git a/src/engine.rs b/src/engine.rs index be49b66..791fa5a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,5 @@ -use crate::config::{AppConfig, ProfileConfig}; +use crate::battery; +use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings}; use crate::core::{OperationalMode, SystemReport, TurboSetting}; use crate::cpu::{self}; use crate::util::error::{ControlError, EngineError}; @@ -22,14 +23,13 @@ where match apply_fn() { Ok(_) => Ok(()), Err(e) => { - if matches!(e, ControlError::NotSupported(_)) - || matches!(e, ControlError::PathMissing(_)) - { + 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)) } } @@ -50,8 +50,10 @@ pub fn determine_and_apply_settings( override_governor.trim() ); - // Apply the override governor setting - validation is handled by set_governor - 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) + })?; } let selected_profile_config: &ProfileConfig; @@ -69,11 +71,15 @@ pub fn determine_and_apply_settings( } } else { // Determine AC/Battery status - // If no batteries, assume AC power (desktop). - // Otherwise, check the ac_connected status from the (first) battery. - // XXX: This relies on the setting ac_connected in BatteryInfo being set correctly. - let on_ac_power = - report.batteries.is_empty() || report.batteries.first().is_some_and(|b| b.ac_connected); + // For desktops (no batteries), we should always use the AC power profile + // For laptops, we check if any battery is present and not connected to AC + let on_ac_power = if report.batteries.is_empty() { + // No batteries means desktop/server, always on AC + true + } else { + // Check if any battery reports AC connected + report.batteries.iter().any(|b| b.ac_connected) + }; if on_ac_power { info!("On AC power, selecting Charger profile."); @@ -90,7 +96,7 @@ pub fn determine_and_apply_settings( // 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(_)) + if matches!(e, ControlError::InvalidValueError(_)) || matches!(e, ControlError::NotSupported(_)) { warn!( @@ -143,6 +149,24 @@ pub fn determine_and_apply_settings( })?; } + // Set battery charge thresholds if configured + if let Some(thresholds) = &selected_profile_config.battery_charge_thresholds { + let start_threshold = thresholds.start; + 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}"), + } + } else { + warn!( + "Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}" + ); + } + } + debug!("Profile settings applied successfully."); Ok(()) @@ -152,6 +176,9 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<() // Get the auto turbo settings from the config, or use defaults let turbo_settings = config.turbo_auto_settings.clone().unwrap_or_default(); + // Validate the complete configuration to ensure it's usable + validate_turbo_auto_settings(&turbo_settings)?; + // Get average CPU temperature and CPU load let cpu_temp = report.cpu_global.average_temperature_celsius; @@ -177,14 +204,6 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<() } }; - // Validate the configuration to ensure it's usable - if turbo_settings.load_threshold_high <= turbo_settings.load_threshold_low { - return Err(EngineError::ConfigurationError( - "Invalid turbo auto settings: high threshold must be greater than low threshold" - .to_string(), - )); - } - // Decision logic for enabling/disabling turbo let enable_turbo = match (cpu_temp, avg_cpu_usage) { // If temperature is too high, disable turbo regardless of load @@ -237,3 +256,30 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<() Err(e) => Err(EngineError::ControlError(e)), } } + +fn validate_turbo_auto_settings(settings: &TurboAutoSettings) -> Result<(), EngineError> { + // Validate load thresholds + if settings.load_threshold_high <= settings.load_threshold_low { + return Err(EngineError::ConfigurationError( + "Invalid turbo auto settings: high threshold must be greater than low threshold" + .to_string(), + )); + } + + // Validate range of load thresholds (should be 0-100%) + if settings.load_threshold_high > 100.0 || settings.load_threshold_low < 0.0 { + return Err(EngineError::ConfigurationError( + "Invalid turbo auto settings: load thresholds must be between 0% and 100%".to_string(), + )); + } + + // Validate temperature threshold (realistic range for CPU temps in Celsius) + if settings.temp_threshold_high <= 0.0 || settings.temp_threshold_high > 110.0 { + return Err(EngineError::ConfigurationError( + "Invalid turbo auto settings: temperature threshold must be between 0°C and 110°C" + .to_string(), + )); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9dfd67e..0b0f1ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod battery; mod cli; mod config; mod conflict; @@ -11,7 +12,7 @@ mod util; use crate::config::AppConfig; use crate::core::{GovernorOverrideMode, TurboSetting}; use crate::util::error::ControlError; -use clap::Parser; +use clap::{Parser, value_parser}; use env_logger::Builder; use log::{debug, error, info}; use std::sync::Once; @@ -77,6 +78,15 @@ enum Commands { }, /// 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, + }, } fn main() { @@ -349,15 +359,74 @@ fn main() { cpu::set_epb(&epb, core_id).map_err(|e| Box::new(e) as Box) } Some(Commands::SetMinFreq { freq_mhz, core_id }) => { - cpu::set_min_frequency(freq_mhz, core_id) - .map_err(|e| Box::new(e) as Box) + // Basic validation for reasonable CPU frequency values + if let Err(e) = validate_freq(freq_mhz, "Minimum") { + error!("{e}"); + Err(e) + } else { + cpu::set_min_frequency(freq_mhz, core_id) + .map_err(|e| Box::new(e) as Box) + } } Some(Commands::SetMaxFreq { freq_mhz, core_id }) => { - cpu::set_max_frequency(freq_mhz, core_id) - .map_err(|e| Box::new(e) as Box) + // Basic validation for reasonable CPU frequency values + if let Err(e) = validate_freq(freq_mhz, "Maximum") { + error!("{e}"); + Err(e) + } else { + cpu::set_max_frequency(freq_mhz, core_id) + .map_err(|e| Box::new(e) as Box) + } + } + 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(|e| Box::new(e) as Box) + } else { + error!( + "Invalid platform profile: '{}'. Available profiles: {}", + profile, + available_profiles.join(", ") + ); + Err(Box::new(ControlError::InvalidProfile(format!( + "Invalid platform profile: '{}'. Available profiles: {}", + profile, + available_profiles.join(", ") + ))) as Box) + } + } + Err(_) => { + // If we can't get profiles (e.g., feature not supported), pass through to the function + // which will provide appropriate error + cpu::set_platform_profile(&profile) + .map_err(|e| Box::new(e) as Box) + } + } + } + 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(Box::new(ControlError::InvalidValueError(format!( + "Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})" + ))) as Box) + } else { + info!( + "Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%" + ); + battery::set_battery_charge_thresholds(start_threshold, stop_threshold) + .map_err(|e| Box::new(e) as Box) + } } - Some(Commands::SetPlatformProfile { profile }) => cpu::set_platform_profile(&profile) - .map_err(|e| Box::new(e) as Box), Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), Some(Commands::Debug) => cli::debug::run_debug(&config), None => { @@ -404,3 +473,21 @@ fn init_logger() { debug!("Logger initialized with RUST_LOG={env_log}"); }); } + +/// Validate CPU frequency input values +fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), Box> { + if freq_mhz == 0 { + error!("{label} frequency cannot be zero"); + Err(Box::new(ControlError::InvalidValueError(format!( + "{label} frequency cannot be zero" + ))) as Box) + } else if freq_mhz > 10000 { + // Extremely high value unlikely to be valid + error!("{label} frequency ({freq_mhz} MHz) is unreasonably high"); + Err(Box::new(ControlError::InvalidValueError(format!( + "{label} frequency ({freq_mhz} MHz) is unreasonably high" + ))) as Box) + } else { + Ok(()) + } +} diff --git a/src/monitor.rs b/src/monitor.rs index a3b6b83..80605ff 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -2,6 +2,7 @@ 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, @@ -48,7 +49,7 @@ pub fn get_system_info() -> SystemInfo { } #[derive(Debug, Clone, Copy)] -struct CpuTimes { +pub struct CpuTimes { user: u64, nice: u64, system: u64, @@ -57,8 +58,6 @@ struct CpuTimes { irq: u64, softirq: u64, steal: u64, - guest: u64, - guest_nice: u64, } impl CpuTimes { @@ -147,18 +146,6 @@ fn read_all_cpu_times() -> Result> { parts[8] )) })?, - guest: parts[9].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse guest time: {}", - parts[9] - )) - })?, - guest_nice: parts[10].parse().map_err(|_| { - SysMonitorError::ProcStatParseError(format!( - "Failed to parse guest_nice time: {}", - parts[10] - )) - })?, }; cpu_times_map.insert(core_id, times); } @@ -288,7 +275,7 @@ pub fn get_cpu_core_info( None } else { let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32)); - Some(usage.max(0.0).min(100.0)) // clamp between 0 and 100 + Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100 } }; @@ -374,7 +361,7 @@ fn get_fallback_temperature(hw_path: &Path) -> Option { 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 + 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() @@ -412,11 +399,13 @@ pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo { eprintln!("Warning: {e}"); 0 }); - let path = (0..core_count) - .map(|i| PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"))) - .find(|path| path.exists()); - if let Some(test_path_buf) = path { - cpufreq_base_path_buf = test_path_buf; + + 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 + } } } @@ -533,7 +522,7 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { } } } else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") { - // fallback for type file missing + // Fallback for type file missing if let Ok(online) = read_sysfs_value::(ps_path.join("online")) { if online == 1 { overall_ac_connected = true; @@ -543,6 +532,12 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { } } + // 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(); @@ -554,6 +549,12 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { 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(); @@ -594,9 +595,94 @@ pub fn get_battery_info(config: &AppConfig) -> Result> { } } } + + // 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(); diff --git a/src/util/error.rs b/src/util/error.rs index 4f9391f..b52480b 100644 --- a/src/util/error.rs +++ b/src/util/error.rs @@ -4,13 +4,13 @@ use std::io; pub enum ControlError { Io(io::Error), WriteError(String), + ReadError(String), InvalidValueError(String), NotSupported(String), PermissionDenied(String), InvalidProfile(String), InvalidGovernor(String), ParseError(String), - ReadError(String), PathMissing(String), } @@ -28,6 +28,7 @@ impl std::fmt::Display for ControlError { match self { Self::Io(e) => write!(f, "I/O error: {e}"), Self::WriteError(s) => write!(f, "Failed to write to sysfs path: {s}"), + Self::ReadError(s) => write!(f, "Failed to read sysfs path: {s}"), Self::InvalidValueError(s) => write!(f, "Invalid value for setting: {s}"), Self::NotSupported(s) => write!(f, "Control action not supported: {s}"), Self::PermissionDenied(s) => { @@ -45,9 +46,6 @@ impl std::fmt::Display for ControlError { Self::ParseError(s) => { write!(f, "Failed to parse value: {s}") } - Self::ReadError(s) => { - write!(f, "Failed to read sysfs path: {s}") - } Self::PathMissing(s) => { write!(f, "Path missing: {s}") } @@ -63,7 +61,6 @@ pub enum SysMonitorError { ReadError(String), ParseError(String), ProcStatParseError(String), - NotAvailable(String), } impl From for SysMonitorError { @@ -81,7 +78,6 @@ impl std::fmt::Display for SysMonitorError { Self::ProcStatParseError(s) => { write!(f, "Failed to parse /proc/stat: {s}") } - Self::NotAvailable(s) => write!(f, "Information not available: {s}"), } } } diff --git a/src/util/mod.rs b/src/util/mod.rs index a91e735..0aa2927 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1 +1,2 @@ pub mod error; +pub mod sysfs; diff --git a/src/util/sysfs.rs b/src/util/sysfs.rs new file mode 100644 index 0000000..e1776e5 --- /dev/null +++ b/src/util/sysfs.rs @@ -0,0 +1,80 @@ +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() +}