diff --git a/src/battery.rs b/src/battery.rs new file mode 100644 index 0000000..4dd9be3 --- /dev/null +++ b/src/battery.rs @@ -0,0 +1,256 @@ +use crate::util::error::ControlError; +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, +} + +/// Represents a battery that supports charge threshold control +pub struct SupportedBattery { + pub name: String, + pub pattern: 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(start_threshold, stop_threshold)?; + + 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(), + )); + } + + 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, start_threshold, stop_threshold) +} + +/// Validates that the threshold values are in acceptable ranges +fn validate_thresholds(start_threshold: u8, stop_threshold: u8) -> Result<()> { + if start_threshold >= stop_threshold { + return Err(ControlError::InvalidValueError(format!( + "Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})" + ))); + } + + if stop_threshold > 100 { + return Err(ControlError::InvalidValueError(format!( + "Stop threshold ({stop_threshold}) cannot exceed 100%" + ))); + } + + Ok(()) +} + +/// 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.flatten() { + 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) +} + +/// 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); + if e.kind() == io::ErrorKind::PermissionDenied { + ControlError::PermissionDenied(error_msg) + } else { + ControlError::WriteError(error_msg) + } + }) +} + +/// Identifies if a battery supports threshold control and which pattern it uses +fn find_battery_with_threshold_support(ps_path: &Path) -> Option { + let threshold_paths = vec![ + 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", + }, + ThresholdPathPattern { + description: "Huawei", + start_path: "charge_start_threshold", + stop_path: "charge_stop_threshold", + }, + // ThinkPad-specific, sometimes used in addition to standard paths + ThresholdPathPattern { + description: "ThinkPad", + start_path: "charge_start_threshold", + stop_path: "charge_stop_threshold", + }, + // Framework laptop support + // FIXME: This needs actual testing. I inferred this behaviour from some + // Framework-specific code, but it may not be correct. + ThresholdPathPattern { + description: "Framework", + start_path: "charge_behaviour_start_threshold", + stop_path: "charge_behaviour_end_threshold", + }, + ]; + + for pattern in &threshold_paths { + let start_threshold_path = ps_path.join(pattern.start_path); + let stop_threshold_path = ps_path.join(pattern.stop_path); + if start_threshold_path.exists() && stop_threshold_path.exists() { + return Some(SupportedBattery { + name: ps_path.file_name()?.to_string_lossy().to_string(), + pattern: pattern.clone(), + path: ps_path.to_path_buf(), + }); + } + } + None +} + +/// 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); + + match ( + write_sysfs_value(&start_path, &start_threshold.to_string()), + write_sysfs_value(&stop_path, &stop_threshold.to_string()), + ) { + (Ok(()), Ok(())) => { + debug!( + "Set {}-{}% charge thresholds for {} battery '{}'", + start_threshold, stop_threshold, battery.pattern.description, battery.name + ); + success_count += 1; + } + (start_result, stop_result) => { + let mut error_msg = format!( + "Failed to set thresholds for {} battery '{}'", + battery.pattern.description, battery.name + ); + if let Err(e) = start_result { + error_msg.push_str(&format!(": start threshold error: {e}")); + } + if let Err(e) = stop_result { + error_msg.push_str(&format!(": stop threshold error: {e}")); + } + errors.push(error_msg); + } + } + } + + if success_count > 0 { + if !errors.is_empty() { + debug!( + "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 = fs::read_to_string(&type_path) + .map_err(|_| ControlError::ReadError(format!("Failed to read {}", type_path.display())))? + .trim() + .to_string(); + + Ok(ps_type == "Battery") +} diff --git a/src/cli/debug.rs b/src/cli/debug.rs index a36018d..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(); // Prefix with underscore to indicate intentionally unused let formatted_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); println!("Timestamp: {formatted_time}"); @@ -246,7 +245,7 @@ fn check_and_print_sysfs_path(path: &str, description: &str) { fn is_systemd_service_active(service_name: &str) -> Result> { let output = Command::new("systemctl") .arg("is-active") - .arg(format!("{}.service", service_name)) + .arg(format!("{service_name}.service")) .stdout(Stdio::piped()) // capture stdout instead of letting it print .stderr(Stdio::null()) // redirect stderr to null .output()?; diff --git a/src/config/load.rs b/src/config/load.rs index 72c9c0c..470a99d 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 { Ok(AppConfig { charger: ProfileConfig::from(charger_profile), battery: ProfileConfig::from(battery_profile), - global_battery_charge_thresholds: toml_app_config.battery_charge_thresholds, ignored_power_supplies: toml_app_config.ignored_power_supplies, - poll_interval_sec: toml_app_config.poll_interval_sec, daemon: DaemonConfig { poll_interval_sec: toml_app_config.daemon.poll_interval_sec, adaptive_interval: toml_app_config.daemon.adaptive_interval, diff --git a/src/config/types.rs b/src/config/types.rs index 83c51b5..52ef5c9 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -38,19 +38,11 @@ pub struct AppConfig { pub charger: ProfileConfig, #[serde(default)] pub battery: ProfileConfig, - #[serde(rename = "battery_charge_thresholds")] - pub global_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 { @@ -225,6 +217,10 @@ impl Default for DaemonConfig { } } +const fn default_poll_interval_sec() -> u64 { + 5 +} + const fn default_adaptive_interval() -> bool { false } diff --git a/src/cpu.rs b/src/cpu.rs index 69ef10a..62d0dd1 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,12 +1,7 @@ use crate::core::{GovernorOverrideMode, TurboSetting}; use crate::util::error::ControlError; use core::str; -use log::debug; -use std::{ - fs, io, - path::{Path, PathBuf}, - string::ToString, -}; +use std::{fs, io, path::Path, string::ToString}; pub type Result = std::result::Result; @@ -170,8 +165,7 @@ pub fn set_epp(epp: &str, core_id: Option) -> Result<()> { } 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. + // EPB is often an integer 0-15. let action = |id: u32| { let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_bias"); if Path::new(&path).exists() { @@ -249,11 +243,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"; @@ -347,196 +343,3 @@ pub fn get_governor_override() -> Option { None } } - -/// 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) -/// -pub fn set_battery_charge_thresholds(start_threshold: u8, stop_threshold: u8) -> Result<()> { - // Validate threshold values - if start_threshold >= stop_threshold { - return Err(ControlError::InvalidValueError(format!( - "Start threshold ({}) must be less than stop threshold ({})", - start_threshold, stop_threshold - ))); - } - - if stop_threshold > 100 { - return Err(ControlError::InvalidValueError(format!( - "Stop threshold ({}) cannot exceed 100%", - stop_threshold - ))); - } - - // Known sysfs paths for battery threshold control by vendor - let threshold_paths = vec![ - // Standard sysfs paths (used by Lenovo and some others) - ThresholdPathPattern { - description: "Standard", - start_path: "charge_control_start_threshold", - stop_path: "charge_control_end_threshold", - }, - // ASUS-specific paths - ThresholdPathPattern { - description: "ASUS", - start_path: "charge_control_start_percentage", - stop_path: "charge_control_end_percentage", - }, - // Huawei-specific paths - ThresholdPathPattern { - description: "Huawei", - start_path: "charge_start_threshold", - stop_path: "charge_stop_threshold", - }, - ]; - - 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(), - )); - } - - 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(); - - // Scan all power supplies for battery threshold support - for entry in entries.flatten() { - let ps_path = entry.path(); - let name = entry.file_name().into_string().unwrap_or_default(); - - // Skip non-battery devices - if !is_battery(&ps_path)? { - continue; - } - - // Try each threshold path pattern for this battery - for pattern in &threshold_paths { - let start_threshold_path = ps_path.join(pattern.start_path); - let stop_threshold_path = ps_path.join(pattern.stop_path); - - if start_threshold_path.exists() && stop_threshold_path.exists() { - // Found a battery with threshold support - supported_batteries.push(SupportedBattery { - name: name.clone(), - pattern: pattern.clone(), - path: ps_path.clone(), - }); - - // Found a supported pattern, no need to check others for this battery - break; - } - } - } - - if supported_batteries.is_empty() { - return Err(ControlError::NotSupported( - "No batteries with charge threshold control support found".to_string(), - )); - } - - // Apply thresholds to all supported batteries - let mut errors = Vec::new(); - let mut success_count = 0; - - for battery in supported_batteries { - let start_path = battery.path.join(battery.pattern.start_path); - let stop_path = battery.path.join(battery.pattern.stop_path); - - // Attempt to set both thresholds - match ( - write_sysfs_value(&start_path, &start_threshold.to_string()), - write_sysfs_value(&stop_path, &stop_threshold.to_string()), - ) { - (Ok(_), Ok(_)) => { - debug!( - "Set {}-{}% charge thresholds for {} battery '{}'", - start_threshold, stop_threshold, battery.pattern.description, battery.name - ); - success_count += 1; - } - (start_result, stop_result) => { - let mut error_msg = format!( - "Failed to set thresholds for {} battery '{}'", - battery.pattern.description, battery.name - ); - - if let Err(e) = start_result { - error_msg.push_str(&format!(": start threshold error: {}", e)); - } - if let Err(e) = stop_result { - error_msg.push_str(&format!(": stop threshold error: {}", e)); - } - - errors.push(error_msg); - } - } - } - - if success_count > 0 { - // As long as we successfully set thresholds on at least one battery, consider it a success - if !errors.is_empty() { - debug!( - "Partial success setting battery thresholds: {}", - errors.join("; ") - ); - } - Ok(()) - } else { - Err(ControlError::WriteError(format!( - "Failed to set charge thresholds on any battery: {}", - errors.join("; ") - ))) - } -} - -/// Helper struct for battery charge threshold path patterns -#[derive(Clone)] -struct ThresholdPathPattern { - description: &'static str, - start_path: &'static str, - stop_path: &'static str, -} - -/// Helper struct for batteries with threshold support -struct SupportedBattery { - name: String, - pattern: ThresholdPathPattern, - path: PathBuf, -} - -/// Check 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 = fs::read_to_string(&type_path) - .map_err(|_| ControlError::ReadError(format!("Failed to read {}", type_path.display())))? - .trim() - .to_string(); - - Ok(ps_type == "Battery") -} diff --git a/src/engine.rs b/src/engine.rs index b1ddb38..67aa8b6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,8 +1,39 @@ +use crate::battery; use crate::config::{AppConfig, ProfileConfig}; use crate::core::{OperationalMode, SystemReport, TurboSetting}; use crate::cpu::{self}; use crate::util::error::{ControlError, EngineError}; -use log::{debug, info}; +use log::{debug, info, warn}; + +/// Try applying a CPU feature and handle common error cases. Centralizes the where we +/// previously did: +/// 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( + feature_name: &str, + value_description: &str, + apply_fn: F, +) -> Result<(), EngineError> +where + F: FnOnce() -> Result, +{ + 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 { + Err(EngineError::ControlError(e)) + } + } + } +} /// Determines the appropriate CPU profile based on power status or forced mode, /// and applies the settings using functions from the `cpu` module. @@ -17,6 +48,8 @@ pub fn determine_and_apply_settings( "Governor override is active: '{}'. Setting governor.", override_governor.trim() ); + + // Apply the override governor setting - validation is handled by set_governor cpu::set_governor(override_governor.trim(), None)?; } @@ -35,11 +68,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 { + // At least one battery exists, check if it's on AC + report.batteries.first().is_some_and(|b| b.ac_connected) + }; if on_ac_power { info!("On AC power, selecting Charger profile."); @@ -53,7 +90,19 @@ pub fn determine_and_apply_settings( // Apply settings from selected_profile_config if let Some(governor) = &selected_profile_config.governor { info!("Setting governor to '{governor}'"); - cpu::set_governor(governor, None)?; + // 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::InvalidValueError(_)) + || matches!(e, ControlError::NotSupported(_)) + { + warn!( + "Configured governor '{governor}' is not available on this system. Skipping." + ); + } else { + return Err(e.into()); + } + } } if let Some(turbo_setting) = selected_profile_config.turbo { @@ -63,40 +112,56 @@ pub fn determine_and_apply_settings( debug!("Managing turbo in auto mode based on system conditions"); manage_auto_turbo(report, selected_profile_config)?; } - _ => cpu::set_turbo(turbo_setting)?, + _ => { + try_apply_feature("Turbo boost", &format!("{turbo_setting:?}"), || { + cpu::set_turbo(turbo_setting) + })?; + } } } if let Some(epp) = &selected_profile_config.epp { - info!("Setting EPP to '{epp}'"); - cpu::set_epp(epp, None)?; + try_apply_feature("EPP", epp, || cpu::set_epp(epp, None))?; } if let Some(epb) = &selected_profile_config.epb { - info!("Setting EPB to '{epb}'"); - cpu::set_epb(epb, None)?; + try_apply_feature("EPB", epb, || cpu::set_epb(epb, None))?; } if let Some(min_freq) = selected_profile_config.min_freq_mhz { - info!("Setting min frequency to '{min_freq} MHz'"); - cpu::set_min_frequency(min_freq, None)?; + try_apply_feature("min frequency", &format!("{min_freq} MHz"), || { + cpu::set_min_frequency(min_freq, None) + })?; } if let Some(max_freq) = selected_profile_config.max_freq_mhz { - info!("Setting max frequency to '{max_freq} MHz'"); - cpu::set_max_frequency(max_freq, None)?; + try_apply_feature("max frequency", &format!("{max_freq} MHz"), || { + cpu::set_max_frequency(max_freq, None) + })?; } if let Some(profile) = &selected_profile_config.platform_profile { - info!("Setting platform profile to '{profile}'"); - cpu::set_platform_profile(profile)?; + try_apply_feature("platform profile", profile, || { + cpu::set_platform_profile(profile) + })?; } - // Apply battery charge thresholds if configured - apply_battery_charge_thresholds( - selected_profile_config.battery_charge_thresholds, - config.global_battery_charge_thresholds, - )?; + // Set battery charge thresholds if configured + if let Some((start_threshold, stop_threshold)) = + selected_profile_config.battery_charge_thresholds + { + 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."); @@ -192,41 +257,3 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<() Err(e) => Err(EngineError::ControlError(e)), } } - -/// Apply battery charge thresholds from configuration -fn apply_battery_charge_thresholds( - profile_thresholds: Option<(u8, u8)>, - global_thresholds: Option<(u8, u8)>, -) -> Result<(), EngineError> { - // Try profile-specific thresholds first, fall back to global thresholds - let thresholds = profile_thresholds.or(global_thresholds); - - if let Some((start_threshold, stop_threshold)) = thresholds { - info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%"); - match cpu::set_battery_charge_thresholds(start_threshold, stop_threshold) { - Ok(()) => { - debug!("Successfully set battery charge thresholds"); - Ok(()) - } - Err(e) => { - // If the battery doesn't support thresholds, log but don't fail - if matches!(e, ControlError::NotSupported(_)) { - debug!("Battery charge thresholds not supported: {e}"); - Ok(()) - } else { - // For permission errors, provide more helpful message - if matches!(e, ControlError::PermissionDenied(_)) { - debug!( - "Permission denied setting battery thresholds - requires root privileges" - ); - } - Err(EngineError::ControlError(e)) - } - } - } - } else { - // No thresholds configured, this is not an error - debug!("No battery charge thresholds configured"); - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs index cf3a4de..dc9bf6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod battery; mod cli; mod config; mod conflict; @@ -370,10 +371,9 @@ fn main() { stop_threshold, }) => { info!( - "Setting battery thresholds: start at {}%, stop at {}%", - start_threshold, stop_threshold + "Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%" ); - cpu::set_battery_charge_thresholds(start_threshold, stop_threshold) + battery::set_battery_charge_thresholds(start_threshold, stop_threshold) .map_err(|e| Box::new(e) as Box) } Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), diff --git a/src/monitor.rs b/src/monitor.rs index 811f3e9..75ff1ce 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, @@ -360,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() @@ -503,7 +504,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; @@ -513,6 +514,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(); @@ -524,6 +531,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(); @@ -564,9 +577,91 @@ 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 { + // Common peripheral battery names + if name.contains("mouse") + || name.contains("keyboard") + || name.contains("trackpad") + || name.contains("gamepad") + || name.contains("controller") + || name.contains("headset") + || name.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 63a6628..51c3162 100644 --- a/src/util/error.rs +++ b/src/util/error.rs @@ -49,7 +49,6 @@ pub enum SysMonitorError { ReadError(String), ParseError(String), ProcStatParseError(String), - NotAvailable(String), } impl From for SysMonitorError { @@ -67,7 +66,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}"), } } }