1
Fork 0
mirror of https://github.com/RGBCube/superfreq synced 2025-07-28 09:27:44 +00:00

Merge pull request #20 from NotAShelf/better-battery-mgnmnt

core: implement better battery management
This commit is contained in:
raf 2025-05-16 05:13:30 +03:00 committed by GitHub
commit 1eeb6d2d90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 992 additions and 161 deletions

View file

@ -111,6 +111,26 @@ sudo superfreq set-min-freq 1200 --core-id 0
sudo superfreq set-max-freq 2800 --core-id 1 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 ## Configuration
Superfreq uses TOML configuration files. Default locations: Superfreq uses TOML configuration files. Default locations:
@ -139,6 +159,8 @@ platform_profile = "performance"
# Min/max frequency in MHz (optional) # Min/max frequency in MHz (optional)
min_freq_mhz = 800 min_freq_mhz = 800
max_freq_mhz = 3500 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 # Settings for when on battery power
[battery] [battery]
@ -149,6 +171,13 @@ epb = "balance_power"
platform_profile = "low-power" platform_profile = "low-power"
min_freq_mhz = 800 min_freq_mhz = 800
max_freq_mhz = 2500 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 configuration
[daemon] [daemon]

262
src/battery.rs Normal file
View file

@ -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<T, E = ControlError> = std::result::Result<T, E>;
/// 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<Vec<SupportedBattery<'static>>> {
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) = &current_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<bool> {
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<SupportedBattery<'static>> {
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
}

View file

@ -5,7 +5,7 @@ use crate::monitor;
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime}; use std::time::Duration;
/// Prints comprehensive debug information about the system /// Prints comprehensive debug information about the system
pub fn run_debug(config: &AppConfig) -> Result<(), Box<dyn Error>> { pub fn run_debug(config: &AppConfig) -> Result<(), Box<dyn Error>> {
@ -13,7 +13,6 @@ pub fn run_debug(config: &AppConfig) -> Result<(), Box<dyn Error>> {
println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("Version: {}", env!("CARGO_PKG_VERSION"));
// Current date and time // Current date and time
let now = SystemTime::now();
let formatted_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); let formatted_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
println!("Timestamp: {formatted_time}"); println!("Timestamp: {formatted_time}");

View file

@ -68,9 +68,7 @@ pub fn load_config_from_path(specific_path: Option<&str>) -> Result<AppConfig, C
Ok(AppConfig { Ok(AppConfig {
charger: ProfileConfig::from(default_toml_config.charger), charger: ProfileConfig::from(default_toml_config.charger),
battery: ProfileConfig::from(default_toml_config.battery), battery: ProfileConfig::from(default_toml_config.battery),
battery_charge_thresholds: default_toml_config.battery_charge_thresholds,
ignored_power_supplies: default_toml_config.ignored_power_supplies, ignored_power_supplies: default_toml_config.ignored_power_supplies,
poll_interval_sec: default_toml_config.poll_interval_sec,
daemon: DaemonConfig::default(), daemon: DaemonConfig::default(),
}) })
} }
@ -82,13 +80,28 @@ fn load_and_parse_config(path: &Path) -> Result<AppConfig, ConfigError> {
let toml_app_config = let toml_app_config =
toml::from_str::<AppConfigToml>(&contents).map_err(ConfigError::TomlError)?; toml::from_str::<AppConfigToml>(&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 // Convert AppConfigToml to AppConfig
Ok(AppConfig { Ok(AppConfig {
charger: ProfileConfig::from(toml_app_config.charger), charger: ProfileConfig::from(charger_profile),
battery: ProfileConfig::from(toml_app_config.battery), battery: ProfileConfig::from(battery_profile),
battery_charge_thresholds: toml_app_config.battery_charge_thresholds,
ignored_power_supplies: toml_app_config.ignored_power_supplies, ignored_power_supplies: toml_app_config.ignored_power_supplies,
poll_interval_sec: toml_app_config.poll_interval_sec,
daemon: DaemonConfig { daemon: DaemonConfig {
poll_interval_sec: toml_app_config.daemon.poll_interval_sec, poll_interval_sec: toml_app_config.daemon.poll_interval_sec,
adaptive_interval: toml_app_config.daemon.adaptive_interval, adaptive_interval: toml_app_config.daemon.adaptive_interval,

View file

@ -1,9 +1,6 @@
pub mod load;
pub mod types;
pub mod watcher; pub mod watcher;
// Re-export all configuration types and functions pub use load::*;
pub use self::load::*; pub use types::*;
pub use self::types::*;
// Internal organization of config submodules
mod load;
mod types;

View file

@ -1,9 +1,47 @@
// Configuration types and structures for superfreq // Configuration types and structures for superfreq
use crate::core::TurboSetting; 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<Self, ConfigError> {
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<Self, Self::Error> {
let (start, stop) = values;
Self::new(start, stop)
}
}
// Structs for configuration using serde::Deserialize // Structs for configuration using serde::Deserialize
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProfileConfig { pub struct ProfileConfig {
pub governor: Option<String>, pub governor: Option<String>,
pub turbo: Option<TurboSetting>, pub turbo: Option<TurboSetting>,
@ -13,6 +51,8 @@ pub struct ProfileConfig {
pub max_freq_mhz: Option<u32>, pub max_freq_mhz: Option<u32>,
pub platform_profile: Option<String>, pub platform_profile: Option<String>,
pub turbo_auto_settings: Option<TurboAutoSettings>, pub turbo_auto_settings: Option<TurboAutoSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
} }
impl Default for ProfileConfig { impl Default for ProfileConfig {
@ -26,28 +66,22 @@ impl Default for ProfileConfig {
max_freq_mhz: None, // no override max_freq_mhz: None, // no override
platform_profile: None, // no override platform_profile: None, // no override
turbo_auto_settings: Some(TurboAutoSettings::default()), turbo_auto_settings: Some(TurboAutoSettings::default()),
battery_charge_thresholds: None,
} }
} }
} }
#[derive(Deserialize, Debug, Default, Clone)] #[derive(Deserialize, Serialize, Debug, Default, Clone)]
pub struct AppConfig { pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub charger: ProfileConfig, pub charger: ProfileConfig,
#[serde(default)] #[serde(default)]
pub battery: ProfileConfig, pub battery: ProfileConfig,
pub battery_charge_thresholds: Option<(u8, u8)>, // (start_threshold, stop_threshold)
pub ignored_power_supplies: Option<Vec<String>>, pub ignored_power_supplies: Option<Vec<String>>,
#[serde(default = "default_poll_interval_sec")]
pub poll_interval_sec: u64,
#[serde(default)] #[serde(default)]
pub daemon: DaemonConfig, pub daemon: DaemonConfig,
} }
const fn default_poll_interval_sec() -> u64 {
5
}
// Error type for config loading // Error type for config loading
#[derive(Debug)] #[derive(Debug)]
pub enum ConfigError { pub enum ConfigError {
@ -55,6 +89,7 @@ pub enum ConfigError {
TomlError(toml::de::Error), TomlError(toml::de::Error),
NoValidConfigFound, NoValidConfigFound,
HomeDirNotFound, HomeDirNotFound,
ValidationError(String),
} }
impl From<std::io::Error> for ConfigError { impl From<std::io::Error> for ConfigError {
@ -76,6 +111,7 @@ impl std::fmt::Display for ConfigError {
Self::TomlError(e) => write!(f, "TOML parsing error: {e}"), Self::TomlError(e) => write!(f, "TOML parsing error: {e}"),
Self::NoValidConfigFound => write!(f, "No valid configuration file found."), Self::NoValidConfigFound => write!(f, "No valid configuration file found."),
Self::HomeDirNotFound => write!(f, "Could not determine user home directory."), 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 {} impl std::error::Error for ConfigError {}
// Intermediate structs for TOML parsing // Intermediate structs for TOML parsing
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ProfileConfigToml { pub struct ProfileConfigToml {
pub governor: Option<String>, pub governor: Option<String>,
pub turbo: Option<String>, // "always", "auto", "never" pub turbo: Option<String>, // "always", "auto", "never"
@ -92,18 +128,19 @@ pub struct ProfileConfigToml {
pub min_freq_mhz: Option<u32>, pub min_freq_mhz: Option<u32>,
pub max_freq_mhz: Option<u32>, pub max_freq_mhz: Option<u32>,
pub platform_profile: Option<String>, pub platform_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
} }
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct AppConfigToml { pub struct AppConfigToml {
#[serde(default)] #[serde(default)]
pub charger: ProfileConfigToml, pub charger: ProfileConfigToml,
#[serde(default)] #[serde(default)]
pub battery: ProfileConfigToml, pub battery: ProfileConfigToml,
pub battery_charge_thresholds: Option<(u8, u8)>, #[serde(skip_serializing_if = "Option::is_none")]
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
pub ignored_power_supplies: Option<Vec<String>>, pub ignored_power_supplies: Option<Vec<String>>,
#[serde(default = "default_poll_interval_sec")]
pub poll_interval_sec: u64,
#[serde(default)] #[serde(default)]
pub daemon: DaemonConfigToml, pub daemon: DaemonConfigToml,
} }
@ -118,11 +155,12 @@ impl Default for ProfileConfigToml {
min_freq_mhz: None, min_freq_mhz: None,
max_freq_mhz: None, max_freq_mhz: None,
platform_profile: None, platform_profile: None,
battery_charge_thresholds: None,
} }
} }
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct TurboAutoSettings { pub struct TurboAutoSettings {
#[serde(default = "default_load_threshold_high")] #[serde(default = "default_load_threshold_high")]
pub load_threshold_high: f32, pub load_threshold_high: f32,
@ -175,11 +213,12 @@ impl From<ProfileConfigToml> for ProfileConfig {
max_freq_mhz: toml_config.max_freq_mhz, max_freq_mhz: toml_config.max_freq_mhz,
platform_profile: toml_config.platform_profile, platform_profile: toml_config.platform_profile,
turbo_auto_settings: Some(TurboAutoSettings::default()), 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 { pub struct DaemonConfig {
#[serde(default = "default_poll_interval_sec")] #[serde(default = "default_poll_interval_sec")]
pub poll_interval_sec: u64, pub poll_interval_sec: u64,
@ -197,7 +236,7 @@ pub struct DaemonConfig {
pub stats_file_path: Option<String>, pub stats_file_path: Option<String>,
} }
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel { pub enum LogLevel {
Error, Error,
Warning, Warning,
@ -219,6 +258,10 @@ impl Default for DaemonConfig {
} }
} }
const fn default_poll_interval_sec() -> u64 {
5
}
const fn default_adaptive_interval() -> bool { const fn default_adaptive_interval() -> bool {
false false
} }
@ -243,7 +286,7 @@ const fn default_stats_file_path() -> Option<String> {
None None
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct DaemonConfigToml { pub struct DaemonConfigToml {
#[serde(default = "default_poll_interval_sec")] #[serde(default = "default_poll_interval_sec")]
pub poll_interval_sec: u64, pub poll_interval_sec: u64,

View file

@ -1,8 +1,8 @@
use clap::ValueEnum; use clap::ValueEnum;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
pub enum TurboSetting { pub enum TurboSetting {
Always, // turbo is forced on (if possible) Always, // turbo is forced on (if possible)
Auto, // system or driver controls turbo Auto, // system or driver controls turbo

View file

@ -1,11 +1,31 @@
use crate::core::{GovernorOverrideMode, TurboSetting}; use crate::core::{GovernorOverrideMode, TurboSetting};
use crate::util::error::ControlError; use crate::util::error::ControlError;
use core::str; use core::str;
use std::path::PathBuf;
use std::{fs, io, path::Path, string::ToString}; use std::{fs, io, path::Path, string::ToString};
pub type Result<T, E = ControlError> = std::result::Result<T, E>; pub type Result<T, E = ControlError> = std::result::Result<T, E>;
// 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 // Write a value to a sysfs file
fn write_sysfs_value(path: impl AsRef<Path>, value: &str) -> Result<()> { fn write_sysfs_value(path: impl AsRef<Path>, value: &str) -> Result<()> {
let p = path.as_ref(); let p = path.as_ref();
@ -83,15 +103,13 @@ where
} }
pub fn set_governor(governor: &str, core_id: Option<u32>) -> Result<()> { pub fn set_governor(governor: &str, core_id: Option<u32>) -> Result<()> {
// First, check if the requested governor is available on the system // Validate the governor is available on this system
let available_governors = get_available_governors()?; // This returns both the validation result and the list of available governors
let (is_valid, available_governors) = is_governor_valid(governor)?;
if !available_governors if !is_valid {
.iter() return Err(ControlError::InvalidValueError(format!(
.any(|g| g.eq_ignore_ascii_case(governor)) "Governor '{}' is not available on this system. Valid governors: {}",
{
return Err(ControlError::InvalidGovernor(format!(
"Governor '{}' is not available. Available governors: {}",
governor, governor,
available_governors.join(", ") available_governors.join(", ")
))); )));
@ -111,53 +129,87 @@ pub fn set_governor(governor: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action) core_id.map_or_else(|| for_each_cpu_core(action), action)
} }
/// Retrieves the list of available CPU governors on the system /// Check if the provided governor is available in the system
pub fn get_available_governors() -> Result<Vec<String>> { /// Returns a tuple of (`is_valid`, `available_governors`) to avoid redundant file reads
// Prefer cpu0, fall back to first cpu with cpufreq fn is_governor_valid(governor: &str) -> Result<(bool, Vec<String>)> {
let mut governor_path = let governors = get_available_governors()?;
PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors");
if !governor_path.exists() { // Convert input governor to lowercase for case-insensitive comparison
let core_count = get_logical_core_count()?; let governor_lower = governor.to_lowercase();
let candidate = (0..core_count)
.map(|i| format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_available_governors")) // Convert all available governors to lowercase for comparison
.find(|path| Path::new(path).exists()); let governors_lower: Vec<String> = governors.iter().map(|g| g.to_lowercase()).collect();
if let Some(path) = candidate {
governor_path = path.into(); // 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<Vec<String>> {
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<String> = 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 cpu0 doesn't have the file or it's empty, scan all CPUs
if e.kind() == io::ErrorKind::PermissionDenied { // This handles heterogeneous systems where cpu0 might not have cpufreq
ControlError::PermissionDenied(format!( if let Ok(entries) = fs::read_dir(cpu_base_path) {
"Permission denied reading from {}", for entry in entries.flatten() {
governor_path.display() let path = entry.path();
)) let file_name = entry.file_name();
} else { let name = match file_name.to_str() {
ControlError::ReadError(format!( Some(name) => name,
"Failed to read from {}: {e}", None => continue,
governor_path.display() };
))
// 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<String> = 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::<Vec<String>>();
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<()> { pub fn set_turbo(setting: TurboSetting) -> Result<()> {
@ -220,6 +272,16 @@ fn try_set_per_core_boost(value: &str) -> Result<bool> {
} }
pub fn set_epp(epp: &str, core_id: Option<u32>) -> Result<()> { pub fn set_epp(epp: &str, core_id: Option<u32>) -> 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 action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_preference"); let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_preference");
if Path::new(&path).exists() { if Path::new(&path).exists() {
@ -231,9 +293,31 @@ pub fn set_epp(epp: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action) core_id.map_or_else(|| for_each_cpu_core(action), action)
} }
/// Get available EPP values from the system
fn get_available_epp_values() -> Result<Vec<String>> {
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<u32>) -> Result<()> { pub fn set_epb(epb: &str, core_id: Option<u32>) -> Result<()> {
// EPB is often an integer 0-15. Ensure `epb` string is valid if parsing. // Validate EPB value - should be a number 0-15 or a recognized string value
// For now, writing it directly as a string. validate_epb_value(epb)?;
let action = |id: u32| { let action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_bias"); let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_bias");
if Path::new(&path).exists() { if Path::new(&path).exists() {
@ -245,8 +329,50 @@ pub fn set_epb(epb: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action) 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::<u8>() {
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<u32>) -> Result<()> { pub fn set_min_frequency(freq_mhz: u32, core_id: Option<u32>) -> 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 action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_min_freq"); let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_min_freq");
if Path::new(&path).exists() { if Path::new(&path).exists() {
@ -259,7 +385,21 @@ pub fn set_min_frequency(freq_mhz: u32, core_id: Option<u32>) -> Result<()> {
} }
pub fn set_max_frequency(freq_mhz: u32, core_id: Option<u32>) -> Result<()> { pub fn set_max_frequency(freq_mhz: u32, core_id: Option<u32>) -> 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 action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_max_freq"); let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_max_freq");
if Path::new(&path).exists() { if Path::new(&path).exists() {
@ -271,6 +411,66 @@ pub fn set_max_frequency(freq_mhz: u32, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action) core_id.map_or_else(|| for_each_cpu_core(action), action)
} }
fn read_sysfs_value_as_u32(path: &str) -> Result<u32> {
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::<u32>()
.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. /// Sets the platform profile.
/// This changes the system performance, temperature, fan, and other hardware replated characteristics. /// 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 /// # Errors
/// ///
/// Returns [`ControlError::NotSupported`] if: /// # Returns
/// - The file `/sys/firmware/acpi/platform_profile_choices` does not exist.
/// - The file `/sys/firmware/acpi/platform_profile_choices` is empty.
/// ///
/// 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<Vec<String>> { pub fn get_platform_profiles() -> Result<Vec<String>> {
let path = "/sys/firmware/acpi/platform_profile_choices"; let path = "/sys/firmware/acpi/platform_profile_choices";
@ -336,7 +538,7 @@ pub fn get_platform_profiles() -> Result<Vec<String>> {
} }
/// Path for storing the governor override state /// 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 /// Force a specific CPU governor or reset to automatic mode
pub fn force_governor(mode: GovernorOverrideMode) -> Result<()> { pub fn force_governor(mode: GovernorOverrideMode) -> Result<()> {

View file

@ -202,16 +202,6 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
Ok(()) Ok(())
} }
/// Initialize the logger with the appropriate level
const fn get_log_level_filter(log_level: LogLevel) -> 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 /// Write current system stats to a file for --stats to read
fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Error> { fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Error> {
let mut file = File::create(path)?; let mut file = File::create(path)?;

View file

@ -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::core::{OperationalMode, SystemReport, TurboSetting};
use crate::cpu::{self}; use crate::cpu::{self};
use crate::util::error::{ControlError, EngineError}; use crate::util::error::{ControlError, EngineError};
@ -22,14 +23,13 @@ where
match apply_fn() { match apply_fn() {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => { Err(e) => {
if matches!(e, ControlError::NotSupported(_)) if matches!(e, ControlError::NotSupported(_)) {
|| matches!(e, ControlError::PathMissing(_))
{
warn!( warn!(
"{feature_name} setting is not supported on this system. Skipping {feature_name} configuration." "{feature_name} setting is not supported on this system. Skipping {feature_name} configuration."
); );
Ok(()) Ok(())
} else { } else {
// Propagate all other errors, including InvalidValueError
Err(EngineError::ControlError(e)) Err(EngineError::ControlError(e))
} }
} }
@ -50,8 +50,10 @@ pub fn determine_and_apply_settings(
override_governor.trim() override_governor.trim()
); );
// Apply the override governor setting - validation is handled by set_governor // Apply the override governor setting
cpu::set_governor(override_governor.trim(), None)?; try_apply_feature("override governor", override_governor.trim(), || {
cpu::set_governor(override_governor.trim(), None)
})?;
} }
let selected_profile_config: &ProfileConfig; let selected_profile_config: &ProfileConfig;
@ -69,11 +71,15 @@ pub fn determine_and_apply_settings(
} }
} else { } else {
// Determine AC/Battery status // Determine AC/Battery status
// If no batteries, assume AC power (desktop). // For desktops (no batteries), we should always use the AC power profile
// Otherwise, check the ac_connected status from the (first) battery. // For laptops, we check if any battery is present and not connected to AC
// XXX: This relies on the setting ac_connected in BatteryInfo being set correctly. let on_ac_power = if report.batteries.is_empty() {
let on_ac_power = // No batteries means desktop/server, always on AC
report.batteries.is_empty() || report.batteries.first().is_some_and(|b| b.ac_connected); true
} else {
// Check if any battery reports AC connected
report.batteries.iter().any(|b| b.ac_connected)
};
if on_ac_power { if on_ac_power {
info!("On AC power, selecting Charger profile."); info!("On AC power, selecting Charger profile.");
@ -90,7 +96,7 @@ pub fn determine_and_apply_settings(
// Let set_governor handle the validation // Let set_governor handle the validation
if let Err(e) = cpu::set_governor(governor, None) { if let Err(e) = cpu::set_governor(governor, None) {
// If the governor is not available, log a warning // If the governor is not available, log a warning
if matches!(e, ControlError::InvalidGovernor(_)) if matches!(e, ControlError::InvalidValueError(_))
|| matches!(e, ControlError::NotSupported(_)) || matches!(e, ControlError::NotSupported(_))
{ {
warn!( 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."); debug!("Profile settings applied successfully.");
Ok(()) 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 // Get the auto turbo settings from the config, or use defaults
let turbo_settings = config.turbo_auto_settings.clone().unwrap_or_default(); 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 // Get average CPU temperature and CPU load
let cpu_temp = report.cpu_global.average_temperature_celsius; 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 // Decision logic for enabling/disabling turbo
let enable_turbo = match (cpu_temp, avg_cpu_usage) { let enable_turbo = match (cpu_temp, avg_cpu_usage) {
// If temperature is too high, disable turbo regardless of load // 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)), 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(())
}

View file

@ -1,3 +1,4 @@
mod battery;
mod cli; mod cli;
mod config; mod config;
mod conflict; mod conflict;
@ -11,7 +12,7 @@ mod util;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::core::{GovernorOverrideMode, TurboSetting}; use crate::core::{GovernorOverrideMode, TurboSetting};
use crate::util::error::ControlError; use crate::util::error::ControlError;
use clap::Parser; use clap::{Parser, value_parser};
use env_logger::Builder; use env_logger::Builder;
use log::{debug, error, info}; use log::{debug, error, info};
use std::sync::Once; use std::sync::Once;
@ -77,6 +78,15 @@ enum Commands {
}, },
/// Set ACPI platform profile /// Set ACPI platform profile
SetPlatformProfile { profile: String }, 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() { fn main() {
@ -349,15 +359,74 @@ fn main() {
cpu::set_epb(&epb, core_id).map_err(|e| Box::new(e) as Box<dyn std::error::Error>) cpu::set_epb(&epb, core_id).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
} }
Some(Commands::SetMinFreq { freq_mhz, core_id }) => { Some(Commands::SetMinFreq { freq_mhz, core_id }) => {
cpu::set_min_frequency(freq_mhz, core_id) // Basic validation for reasonable CPU frequency values
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>) 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<dyn std::error::Error>)
}
} }
Some(Commands::SetMaxFreq { freq_mhz, core_id }) => { Some(Commands::SetMaxFreq { freq_mhz, core_id }) => {
cpu::set_max_frequency(freq_mhz, core_id) // Basic validation for reasonable CPU frequency values
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>) 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<dyn std::error::Error>)
}
}
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<dyn std::error::Error>)
} 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<dyn std::error::Error>)
}
}
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<dyn std::error::Error>)
}
}
}
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<dyn std::error::Error>)
} else {
info!(
"Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%"
);
battery::set_battery_charge_thresholds(start_threshold, stop_threshold)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
} }
Some(Commands::SetPlatformProfile { profile }) => cpu::set_platform_profile(&profile)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>),
Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose),
Some(Commands::Debug) => cli::debug::run_debug(&config), Some(Commands::Debug) => cli::debug::run_debug(&config),
None => { None => {
@ -404,3 +473,21 @@ fn init_logger() {
debug!("Logger initialized with RUST_LOG={env_log}"); debug!("Logger initialized with RUST_LOG={env_log}");
}); });
} }
/// Validate CPU frequency input values
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), Box<dyn std::error::Error>> {
if freq_mhz == 0 {
error!("{label} frequency cannot be zero");
Err(Box::new(ControlError::InvalidValueError(format!(
"{label} frequency cannot be zero"
))) as Box<dyn std::error::Error>)
} 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<dyn std::error::Error>)
} else {
Ok(())
}
}

View file

@ -2,6 +2,7 @@ use crate::config::AppConfig;
use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport}; use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport};
use crate::cpu::get_logical_core_count; use crate::cpu::get_logical_core_count;
use crate::util::error::SysMonitorError; use crate::util::error::SysMonitorError;
use log::debug;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs, fs,
@ -48,7 +49,7 @@ pub fn get_system_info() -> SystemInfo {
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct CpuTimes { pub struct CpuTimes {
user: u64, user: u64,
nice: u64, nice: u64,
system: u64, system: u64,
@ -57,8 +58,6 @@ struct CpuTimes {
irq: u64, irq: u64,
softirq: u64, softirq: u64,
steal: u64, steal: u64,
guest: u64,
guest_nice: u64,
} }
impl CpuTimes { impl CpuTimes {
@ -147,18 +146,6 @@ fn read_all_cpu_times() -> Result<HashMap<u32, CpuTimes>> {
parts[8] 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); cpu_times_map.insert(core_id, times);
} }
@ -288,7 +275,7 @@ pub fn get_cpu_core_info(
None None
} else { } else {
let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32)); 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<f32> {
pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> { pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> {
let initial_cpu_times = read_all_cpu_times()?; 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 final_cpu_times = read_all_cpu_times()?;
let num_cores = get_logical_core_count() let num_cores = get_logical_core_count()
@ -412,11 +399,13 @@ pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo {
eprintln!("Warning: {e}"); eprintln!("Warning: {e}");
0 0
}); });
let path = (0..core_count)
.map(|i| PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"))) for i in 0..core_count {
.find(|path| path.exists()); let test_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"));
if let Some(test_path_buf) = path { if test_path.exists() {
cpufreq_base_path_buf = test_path_buf; 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<Vec<BatteryInfo>> {
} }
} }
} else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") { } 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::<u8>(ps_path.join("online")) { if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
if online == 1 { if online == 1 {
overall_ac_connected = true; overall_ac_connected = true;
@ -543,6 +532,12 @@ pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
} }
} }
// 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)? { for entry in fs::read_dir(power_supply_path)? {
let entry = entry?; let entry = entry?;
let ps_path = entry.path(); let ps_path = entry.path();
@ -554,6 +549,12 @@ pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) { if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
if ps_type == "Battery" { 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 status_str = read_sysfs_file_trimmed(ps_path.join("status")).ok();
let capacity_percent = read_sysfs_value::<u8>(ps_path.join("capacity")).ok(); let capacity_percent = read_sysfs_value::<u8>(ps_path.join("capacity")).ok();
@ -594,9 +595,94 @@ pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
} }
} }
} }
// 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) 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::<i32>(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<SystemLoad> { pub fn get_system_load() -> Result<SystemLoad> {
let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?; let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?;
let parts: Vec<&str> = loadavg_str.split_whitespace().collect(); let parts: Vec<&str> = loadavg_str.split_whitespace().collect();

View file

@ -4,13 +4,13 @@ use std::io;
pub enum ControlError { pub enum ControlError {
Io(io::Error), Io(io::Error),
WriteError(String), WriteError(String),
ReadError(String),
InvalidValueError(String), InvalidValueError(String),
NotSupported(String), NotSupported(String),
PermissionDenied(String), PermissionDenied(String),
InvalidProfile(String), InvalidProfile(String),
InvalidGovernor(String), InvalidGovernor(String),
ParseError(String), ParseError(String),
ReadError(String),
PathMissing(String), PathMissing(String),
} }
@ -28,6 +28,7 @@ impl std::fmt::Display for ControlError {
match self { match self {
Self::Io(e) => write!(f, "I/O error: {e}"), Self::Io(e) => write!(f, "I/O error: {e}"),
Self::WriteError(s) => write!(f, "Failed to write to sysfs path: {s}"), 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::InvalidValueError(s) => write!(f, "Invalid value for setting: {s}"),
Self::NotSupported(s) => write!(f, "Control action not supported: {s}"), Self::NotSupported(s) => write!(f, "Control action not supported: {s}"),
Self::PermissionDenied(s) => { Self::PermissionDenied(s) => {
@ -45,9 +46,6 @@ impl std::fmt::Display for ControlError {
Self::ParseError(s) => { Self::ParseError(s) => {
write!(f, "Failed to parse value: {s}") write!(f, "Failed to parse value: {s}")
} }
Self::ReadError(s) => {
write!(f, "Failed to read sysfs path: {s}")
}
Self::PathMissing(s) => { Self::PathMissing(s) => {
write!(f, "Path missing: {s}") write!(f, "Path missing: {s}")
} }
@ -63,7 +61,6 @@ pub enum SysMonitorError {
ReadError(String), ReadError(String),
ParseError(String), ParseError(String),
ProcStatParseError(String), ProcStatParseError(String),
NotAvailable(String),
} }
impl From<io::Error> for SysMonitorError { impl From<io::Error> for SysMonitorError {
@ -81,7 +78,6 @@ impl std::fmt::Display for SysMonitorError {
Self::ProcStatParseError(s) => { Self::ProcStatParseError(s) => {
write!(f, "Failed to parse /proc/stat: {s}") write!(f, "Failed to parse /proc/stat: {s}")
} }
Self::NotAvailable(s) => write!(f, "Information not available: {s}"),
} }
} }
} }

View file

@ -1 +1,2 @@
pub mod error; pub mod error;
pub mod sysfs;

80
src/util/sysfs.rs Normal file
View file

@ -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<Path>, 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<Path>) -> Result<String, ControlError> {
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()
}