diff --git a/src/battery.rs b/src/battery.rs deleted file mode 100644 index b15e6bb..0000000 --- a/src/battery.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crate::{config::types::BatteryChargeThresholds, util::error::ControlError, util::sysfs}; -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) => { - log::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() { - log::warn!("No batteries with charge threshold support found"); - } else { - log::debug!( - "Found {} batteries with threshold support", - supported_batteries.len() - ); - for battery in &supported_batteries { - log::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(()) => { - log::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 { - log::warn!( - "Failed to restore previous stop threshold for battery '{}': {}. Battery may be in an inconsistent state.", - battery.name, - re - ); - } else { - log::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() { - log::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/config/types.rs b/src/config/types.rs index eb9ce7f..3150fc5 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -15,12 +15,12 @@ macro_rules! default_const { } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] -pub struct BatteryChargeThresholds { +pub struct PowerSupplyChargeThresholds { pub start: u8, pub stop: u8, } -impl BatteryChargeThresholds { +impl PowerSupplyChargeThresholds { pub fn new(start: u8, stop: u8) -> Result { if stop == 0 { return Err(ConfigError::Validation( @@ -42,7 +42,7 @@ impl BatteryChargeThresholds { } } -impl TryFrom<(u8, u8)> for BatteryChargeThresholds { +impl TryFrom<(u8, u8)> for PowerSupplyChargeThresholds { type Error = ConfigError; fn try_from(values: (u8, u8)) -> Result { @@ -66,7 +66,7 @@ pub struct ProfileConfig { #[serde(default)] pub enable_auto_turbo: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, + pub battery_charge_thresholds: Option, } impl Default for ProfileConfig { @@ -124,7 +124,7 @@ pub struct ProfileConfigToml { #[serde(default = "default_enable_auto_turbo")] pub enable_auto_turbo: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, + pub battery_charge_thresholds: Option, } #[derive(Deserialize, Serialize, Debug, Clone, Default)] @@ -134,7 +134,7 @@ pub struct AppConfigToml { #[serde(default)] pub battery: ProfileConfigToml, #[serde(skip_serializing_if = "Option::is_none")] - pub battery_charge_thresholds: Option, + pub battery_charge_thresholds: Option, pub ignored_power_supplies: Option>, #[serde(default)] pub daemon: DaemonConfigToml, diff --git a/src/engine.rs b/src/engine.rs index fadff3b..0aa2644 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,7 +1,7 @@ -use crate::battery; use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings}; use crate::core::{OperationalMode, SystemReport, TurboSetting}; use crate::cpu::{self}; +use crate::power_supply; use crate::util::error::{ControlError, EngineError}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -277,7 +277,7 @@ pub fn determine_and_apply_settings( if start_threshold < stop_threshold && stop_threshold <= 100 { log::info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%"); - match battery::set_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}"), } diff --git a/src/main.rs b/src/main.rs index 333cf64..bc4b0c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod battery; mod cli; mod config; mod core; @@ -6,6 +5,7 @@ mod cpu; mod daemon; mod engine; mod monitor; +mod power_supply; mod util; use anyhow::{Context, anyhow, bail}; @@ -148,27 +148,14 @@ fn real_main() -> anyhow::Result<()> { cpu::set_platform_profile(platform_profile)?; } - // TODO: This is like this because [`cpu`] doesn't expose - // a way of setting them individually. Will clean this up - // after that is cleaned. - if charge_threshold_start.is_some() || charge_threshold_end.is_some() { - let charge_threshold_start = charge_threshold_start.ok_or_else(|| { - anyhow!("both charge thresholds should be given at the same time") - })?; - let charge_threshold_end = charge_threshold_end.ok_or_else(|| { - anyhow!("both charge thresholds should be given at the same time") - })?; - - if charge_threshold_start >= charge_threshold_end { - bail!( - "charge start threshold (given as {charge_threshold_start}) must be less than stop threshold (given as {charge_threshold_end})" - ); + for power_supply in power_supply::get_power_supplies()? { + if let Some(threshold_start) = charge_threshold_start { + power_supply::set_charge_threshold_start(&power_supply, threshold_start)?; } - battery::set_battery_charge_thresholds( - charge_threshold_start, - charge_threshold_end, - )?; + if let Some(threshold_end) = charge_threshold_end { + power_supply::set_charge_threshold_end(&power_supply, threshold_end)?; + } } Ok(()) diff --git a/src/power_supply.rs b/src/power_supply.rs new file mode 100644 index 0000000..7320f18 --- /dev/null +++ b/src/power_supply.rs @@ -0,0 +1,175 @@ +use anyhow::Context; + +use std::{ + fmt, fs, + path::{Path, PathBuf}, +}; + +/// Represents a pattern of path suffixes used to control charge thresholds +/// for different device vendors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PowerSupplyConfig { + pub manufacturer: &'static str, + pub path_start: &'static str, + pub path_end: &'static str, +} + +/// Charge threshold configs. +const POWER_SUPPLY_CONFIGS: &[PowerSupplyConfig] = &[ + PowerSupplyConfig { + manufacturer: "Standard", + path_start: "charge_control_start_threshold", + path_end: "charge_control_end_threshold", + }, + PowerSupplyConfig { + manufacturer: "ASUS", + path_start: "charge_control_start_percentage", + path_end: "charge_control_end_percentage", + }, + // Combine Huawei and ThinkPad since they use identical paths. + PowerSupplyConfig { + manufacturer: "ThinkPad/Huawei", + path_start: "charge_start_threshold", + path_end: "charge_stop_threshold", + }, + // Framework laptop support. + PowerSupplyConfig { + manufacturer: "Framework", + path_start: "charge_behaviour_start_threshold", + path_end: "charge_behaviour_end_threshold", + }, +]; + +/// Represents a power supply that supports charge threshold control. +pub struct PowerSupply { + pub name: String, + pub path: PathBuf, + pub config: PowerSupplyConfig, +} + +impl fmt::Display for PowerSupply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "power suppply '{name}' from manufacturer '{manufacturer}'", + name = &self.name, + manufacturer = &self.config.manufacturer, + ) + } +} + +impl PowerSupply { + pub fn charge_threshold_path_start(&self) -> PathBuf { + self.path.join(self.config.path_start) + } + + pub fn charge_threshold_path_end(&self) -> PathBuf { + self.path.join(self.config.path_end) + } +} + +// TODO: Migrate to central utils file. Same exists in cpu.rs. +fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { + let path = path.as_ref(); + + fs::write(path, value).with_context(|| { + format!( + "failed to write '{value}' to '{path}'", + path = path.display(), + ) + }) +} + +fn is_power_supply(path: &Path) -> anyhow::Result { + let type_path = path.join("type"); + + let type_ = fs::read_to_string(&type_path) + .with_context(|| format!("failed to read '{path}'", path = type_path.display()))?; + + Ok(type_ == "Battery") +} + +/// Get all batteries in the system that support threshold control. +pub fn get_power_supplies() -> anyhow::Result> { + const PATH: &str = "/sys/class/power_supply"; + + let mut power_supplies = Vec::new(); + + 'entries: for entry in fs::read_dir(PATH).with_context(|| format!("failed to read '{PATH}'"))? { + let entry = match entry { + Ok(entry) => entry, + + Err(error) => { + log::warn!("failed to read power supply entry: {error}"); + continue; + } + }; + + let entry_path = entry.path(); + + if !is_power_supply(&entry_path).with_context(|| { + format!( + "failed to determine whether if '{path}' is a power supply", + path = entry_path.display(), + ) + })? { + continue; + } + + for config in POWER_SUPPLY_CONFIGS { + if entry_path.join(config.path_start).exists() + && entry_path.join(config.path_end).exists() + { + power_supplies.push(PowerSupply { + name: entry_path + .file_name() + .with_context(|| { + format!( + "failed to get file name of '{path}'", + path = entry_path.display(), + ) + })? + .to_string_lossy() + .to_string(), + + path: entry_path, + + config: *config, + }); + continue 'entries; + } + } + } + + Ok(power_supplies) +} + +pub fn set_charge_threshold_start( + power_supply: &PowerSupply, + charge_threshold_start: u8, +) -> anyhow::Result<()> { + write( + &power_supply.charge_threshold_path_start(), + &charge_threshold_start.to_string(), + ) + .with_context(|| format!("failed to set charge threshold start for {power_supply}"))?; + + log::info!("set battery threshold start for {power_supply} to {charge_threshold_start}%"); + + Ok(()) +} + +pub fn set_charge_threshold_end( + power_supply: &PowerSupply, + charge_threshold_end: u8, +) -> anyhow::Result<()> { + write( + &power_supply.charge_threshold_path_end(), + &charge_threshold_end.to_string(), + ) + .with_context(|| format!("failed to set charge threshold end for {power_supply}"))?; + + log::info!("set battery threshold end for {power_supply} to {charge_threshold_end}%"); + + Ok(()) +}