1
Fork 0
mirror of https://github.com/RGBCube/superfreq synced 2025-07-28 01:17:45 +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
```
### Battery Management
```bash
# Set battery charging thresholds to extend battery lifespan
sudo superfreq set-battery-thresholds 40 80 # Start charging at 40%, stop at 80%
```
Battery charging thresholds help extend battery longevity by preventing constant
charging to 100%. Different laptop vendors implement this feature differently,
but Superfreq attempts to support multiple vendor implementations including:
- Lenovo ThinkPad/IdeaPad (Standard implementation)
- ASUS laptops
- Huawei laptops
- Other devices using the standard Linux power_supply API
Note that battery management is sensitive, and that your mileage may vary.
Please open an issue if your vendor is not supported, but patches would help
more than issue reports, as supporting hardware _needs_ hardware.
## Configuration
Superfreq uses TOML configuration files. Default locations:
@ -139,6 +159,8 @@ platform_profile = "performance"
# Min/max frequency in MHz (optional)
min_freq_mhz = 800
max_freq_mhz = 3500
# Optional: Profile-specific battery charge thresholds (overrides global setting)
# battery_charge_thresholds = [40, 80] # Start at 40%, stop at 80%
# Settings for when on battery power
[battery]
@ -149,6 +171,13 @@ epb = "balance_power"
platform_profile = "low-power"
min_freq_mhz = 800
max_freq_mhz = 2500
# Optional: Profile-specific battery charge thresholds (overrides global setting)
# battery_charge_thresholds = [60, 80] # Start at 60%, stop at 80% (more conservative)
# Global battery charging thresholds (applied to both profiles unless overridden)
# Start charging at 40%, stop at 80% - extends battery lifespan
# NOTE: Profile-specific thresholds (in [charger] or [battery] sections) take precedence over this global setting
battery_charge_thresholds = [40, 80]
# Daemon configuration
[daemon]

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::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<dyn Error>> {
@ -13,7 +13,6 @@ pub fn run_debug(config: &AppConfig) -> Result<(), Box<dyn Error>> {
println!("Version: {}", env!("CARGO_PKG_VERSION"));
// Current date and time
let now = SystemTime::now();
let formatted_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
println!("Timestamp: {formatted_time}");

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,31 @@
use crate::core::{GovernorOverrideMode, TurboSetting};
use crate::util::error::ControlError;
use core::str;
use std::path::PathBuf;
use std::{fs, io, path::Path, string::ToString};
pub type Result<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
fn write_sysfs_value(path: impl AsRef<Path>, value: &str) -> Result<()> {
let p = path.as_ref();
@ -83,15 +103,13 @@ where
}
pub fn set_governor(governor: &str, core_id: Option<u32>) -> Result<()> {
// First, check if the requested governor is available on the system
let available_governors = get_available_governors()?;
// Validate the governor is available on this system
// This returns both the validation result and the list of available governors
let (is_valid, available_governors) = is_governor_valid(governor)?;
if !available_governors
.iter()
.any(|g| g.eq_ignore_ascii_case(governor))
{
return Err(ControlError::InvalidGovernor(format!(
"Governor '{}' is not available. Available governors: {}",
if !is_valid {
return Err(ControlError::InvalidValueError(format!(
"Governor '{}' is not available on this system. Valid governors: {}",
governor,
available_governors.join(", ")
)));
@ -111,53 +129,87 @@ pub fn set_governor(governor: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action)
}
/// Retrieves the list of available CPU governors on the system
pub fn get_available_governors() -> Result<Vec<String>> {
// Prefer cpu0, fall back to first cpu with cpufreq
let mut governor_path =
PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors");
if !governor_path.exists() {
let core_count = get_logical_core_count()?;
let candidate = (0..core_count)
.map(|i| format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_available_governors"))
.find(|path| Path::new(path).exists());
if let Some(path) = candidate {
governor_path = path.into();
}
}
if !governor_path.exists() {
return Err(ControlError::NotSupported(
"Could not determine available governors".to_string(),
));
}
/// Check if the provided governor is available in the system
/// Returns a tuple of (`is_valid`, `available_governors`) to avoid redundant file reads
fn is_governor_valid(governor: &str) -> Result<(bool, Vec<String>)> {
let governors = get_available_governors()?;
let content = fs::read_to_string(&governor_path).map_err(|e| {
if e.kind() == io::ErrorKind::PermissionDenied {
ControlError::PermissionDenied(format!(
"Permission denied reading from {}",
governor_path.display()
))
} else {
ControlError::ReadError(format!(
"Failed to read from {}: {e}",
governor_path.display()
))
}
// Convert input governor to lowercase for case-insensitive comparison
let governor_lower = governor.to_lowercase();
// Convert all available governors to lowercase for comparison
let governors_lower: Vec<String> = governors.iter().map(|g| g.to_lowercase()).collect();
// Check if the lowercase governor is in the lowercase list
Ok((governors_lower.contains(&governor_lower), governors))
}
/// Get available CPU governors from the system
fn get_available_governors() -> Result<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}"))
})?;
// Parse the space-separated list of governors
let governors = content
let governors: Vec<String> = content
.split_whitespace()
.map(ToString::to_string)
.collect::<Vec<String>>();
.collect();
if governors.is_empty() {
return Err(ControlError::ParseError(
"No available governors found".to_string(),
));
if !governors.is_empty() {
return Ok(governors);
}
}
Ok(governors)
// If cpu0 doesn't have the file or it's empty, scan all CPUs
// This handles heterogeneous systems where cpu0 might not have cpufreq
if let Ok(entries) = fs::read_dir(cpu_base_path) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name();
let name = match file_name.to_str() {
Some(name) => name,
None => continue,
};
// 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
}
}
}
}
// If we get here, we couldn't find any valid governors list
Err(ControlError::NotSupported(
"Could not determine available governors on any CPU".to_string(),
))
}
pub fn set_turbo(setting: TurboSetting) -> Result<()> {
@ -220,6 +272,16 @@ fn try_set_per_core_boost(value: &str) -> Result<bool> {
}
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 path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_preference");
if Path::new(&path).exists() {
@ -231,9 +293,31 @@ pub fn set_epp(epp: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action)
}
/// Get available EPP values from the system
fn get_available_epp_values() -> Result<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<()> {
// EPB is often an integer 0-15. Ensure `epb` string is valid if parsing.
// For now, writing it directly as a string.
// Validate EPB value - should be a number 0-15 or a recognized string value
validate_epb_value(epb)?;
let action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/energy_performance_bias");
if Path::new(&path).exists() {
@ -245,8 +329,50 @@ pub fn set_epb(epb: &str, core_id: Option<u32>) -> Result<()> {
core_id.map_or_else(|| for_each_cpu_core(action), action)
}
fn validate_epb_value(epb: &str) -> Result<()> {
// EPB can be a number from 0-15 or a recognized string
// Try parsing as a number first
if let Ok(value) = epb.parse::<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<()> {
let freq_khz_str = (freq_mhz * 1000).to_string();
// Check if the new minimum frequency would be greater than current maximum
if let Some(id) = core_id {
validate_min_frequency(id, freq_mhz)?;
} else {
// Check for all cores
let num_cores = get_logical_core_count()?;
for id in 0..num_cores {
validate_min_frequency(id, freq_mhz)?;
}
}
// XXX: We use u64 for the intermediate calculation to prevent overflow
let freq_khz = u64::from(freq_mhz) * 1000;
let freq_khz_str = freq_khz.to_string();
let action = |id: u32| {
let path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_min_freq");
if Path::new(&path).exists() {
@ -259,7 +385,21 @@ pub fn set_min_frequency(freq_mhz: u32, core_id: Option<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 path = format!("/sys/devices/system/cpu/cpu{id}/cpufreq/scaling_max_freq");
if Path::new(&path).exists() {
@ -271,6 +411,66 @@ pub fn set_max_frequency(freq_mhz: u32, core_id: Option<u32>) -> Result<()> {
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.
/// This changes the system performance, temperature, fan, and other hardware replated characteristics.
///
@ -311,11 +511,13 @@ pub fn set_platform_profile(profile: &str) -> Result<()> {
///
/// # Errors
///
/// Returns [`ControlError::NotSupported`] if:
/// # 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 [`ControlError::PermissionDenied`] if the file `/sys/firmware/acpi/platform_profile_choices` cannot be read.
/// - [`ControlError::PermissionDenied`] if the file `/sys/firmware/acpi/platform_profile_choices` cannot be read.
///
pub fn get_platform_profiles() -> Result<Vec<String>> {
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
const GOVERNOR_OVERRIDE_PATH: &str = "/etc/superfreq/governor_override";
const GOVERNOR_OVERRIDE_PATH: &str = "/etc/xdg/superfreq/governor_override";
/// Force a specific CPU governor or reset to automatic mode
pub fn force_governor(mode: GovernorOverrideMode) -> Result<()> {

View file

@ -202,16 +202,6 @@ pub fn run_daemon(mut config: AppConfig, verbose: bool) -> Result<(), Box<dyn st
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
fn write_stats_file(path: &str, report: &SystemReport) -> Result<(), std::io::Error> {
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::cpu::{self};
use crate::util::error::{ControlError, EngineError};
@ -22,14 +23,13 @@ where
match apply_fn() {
Ok(_) => Ok(()),
Err(e) => {
if matches!(e, ControlError::NotSupported(_))
|| matches!(e, ControlError::PathMissing(_))
{
if matches!(e, ControlError::NotSupported(_)) {
warn!(
"{feature_name} setting is not supported on this system. Skipping {feature_name} configuration."
);
Ok(())
} else {
// Propagate all other errors, including InvalidValueError
Err(EngineError::ControlError(e))
}
}
@ -50,8 +50,10 @@ pub fn determine_and_apply_settings(
override_governor.trim()
);
// Apply the override governor setting - validation is handled by set_governor
cpu::set_governor(override_governor.trim(), None)?;
// Apply the override governor setting
try_apply_feature("override governor", override_governor.trim(), || {
cpu::set_governor(override_governor.trim(), None)
})?;
}
let selected_profile_config: &ProfileConfig;
@ -69,11 +71,15 @@ pub fn determine_and_apply_settings(
}
} else {
// Determine AC/Battery status
// If no batteries, assume AC power (desktop).
// Otherwise, check the ac_connected status from the (first) battery.
// XXX: This relies on the setting ac_connected in BatteryInfo being set correctly.
let on_ac_power =
report.batteries.is_empty() || report.batteries.first().is_some_and(|b| b.ac_connected);
// For desktops (no batteries), we should always use the AC power profile
// For laptops, we check if any battery is present and not connected to AC
let on_ac_power = if report.batteries.is_empty() {
// No batteries means desktop/server, always on AC
true
} else {
// Check if any battery reports AC connected
report.batteries.iter().any(|b| b.ac_connected)
};
if on_ac_power {
info!("On AC power, selecting Charger profile.");
@ -90,7 +96,7 @@ pub fn determine_and_apply_settings(
// Let set_governor handle the validation
if let Err(e) = cpu::set_governor(governor, None) {
// If the governor is not available, log a warning
if matches!(e, ControlError::InvalidGovernor(_))
if matches!(e, ControlError::InvalidValueError(_))
|| matches!(e, ControlError::NotSupported(_))
{
warn!(
@ -143,6 +149,24 @@ pub fn determine_and_apply_settings(
})?;
}
// Set battery charge thresholds if configured
if let Some(thresholds) = &selected_profile_config.battery_charge_thresholds {
let start_threshold = thresholds.start;
let stop_threshold = thresholds.stop;
if start_threshold < stop_threshold && stop_threshold <= 100 {
info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%");
match battery::set_battery_charge_thresholds(start_threshold, stop_threshold) {
Ok(()) => debug!("Battery charge thresholds set successfully"),
Err(e) => warn!("Failed to set battery charge thresholds: {e}"),
}
} else {
warn!(
"Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}"
);
}
}
debug!("Profile settings applied successfully.");
Ok(())
@ -152,6 +176,9 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<()
// Get the auto turbo settings from the config, or use defaults
let turbo_settings = config.turbo_auto_settings.clone().unwrap_or_default();
// Validate the complete configuration to ensure it's usable
validate_turbo_auto_settings(&turbo_settings)?;
// Get average CPU temperature and CPU load
let cpu_temp = report.cpu_global.average_temperature_celsius;
@ -177,14 +204,6 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<()
}
};
// Validate the configuration to ensure it's usable
if turbo_settings.load_threshold_high <= turbo_settings.load_threshold_low {
return Err(EngineError::ConfigurationError(
"Invalid turbo auto settings: high threshold must be greater than low threshold"
.to_string(),
));
}
// Decision logic for enabling/disabling turbo
let enable_turbo = match (cpu_temp, avg_cpu_usage) {
// If temperature is too high, disable turbo regardless of load
@ -237,3 +256,30 @@ fn manage_auto_turbo(report: &SystemReport, config: &ProfileConfig) -> Result<()
Err(e) => Err(EngineError::ControlError(e)),
}
}
fn validate_turbo_auto_settings(settings: &TurboAutoSettings) -> Result<(), EngineError> {
// Validate load thresholds
if settings.load_threshold_high <= settings.load_threshold_low {
return Err(EngineError::ConfigurationError(
"Invalid turbo auto settings: high threshold must be greater than low threshold"
.to_string(),
));
}
// Validate range of load thresholds (should be 0-100%)
if settings.load_threshold_high > 100.0 || settings.load_threshold_low < 0.0 {
return Err(EngineError::ConfigurationError(
"Invalid turbo auto settings: load thresholds must be between 0% and 100%".to_string(),
));
}
// Validate temperature threshold (realistic range for CPU temps in Celsius)
if settings.temp_threshold_high <= 0.0 || settings.temp_threshold_high > 110.0 {
return Err(EngineError::ConfigurationError(
"Invalid turbo auto settings: temperature threshold must be between 0°C and 110°C"
.to_string(),
));
}
Ok(())
}

View file

@ -1,3 +1,4 @@
mod battery;
mod cli;
mod config;
mod conflict;
@ -11,7 +12,7 @@ mod util;
use crate::config::AppConfig;
use crate::core::{GovernorOverrideMode, TurboSetting};
use crate::util::error::ControlError;
use clap::Parser;
use clap::{Parser, value_parser};
use env_logger::Builder;
use log::{debug, error, info};
use std::sync::Once;
@ -77,6 +78,15 @@ enum Commands {
},
/// Set ACPI platform profile
SetPlatformProfile { profile: String },
/// Set battery charge thresholds to extend battery lifespan
SetBatteryThresholds {
/// Percentage at which charging starts (when below this value)
#[clap(value_parser = value_parser!(u8).range(0..=99))]
start_threshold: u8,
/// Percentage at which charging stops (when it reaches this value)
#[clap(value_parser = value_parser!(u8).range(1..=100))]
stop_threshold: u8,
},
}
fn main() {
@ -349,15 +359,74 @@ fn main() {
cpu::set_epb(&epb, core_id).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
Some(Commands::SetMinFreq { freq_mhz, core_id }) => {
// Basic validation for reasonable CPU frequency values
if let Err(e) = validate_freq(freq_mhz, "Minimum") {
error!("{e}");
Err(e)
} else {
cpu::set_min_frequency(freq_mhz, core_id)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
}
Some(Commands::SetMaxFreq { freq_mhz, core_id }) => {
// Basic validation for reasonable CPU frequency values
if let Err(e) = validate_freq(freq_mhz, "Maximum") {
error!("{e}");
Err(e)
} else {
cpu::set_max_frequency(freq_mhz, core_id)
.map_err(|e| Box::new(e) as Box<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::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::Daemon { verbose }) => daemon::run_daemon(config, verbose),
Some(Commands::Debug) => cli::debug::run_debug(&config),
None => {
@ -404,3 +473,21 @@ fn init_logger() {
debug!("Logger initialized with RUST_LOG={env_log}");
});
}
/// Validate CPU frequency input values
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), Box<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::cpu::get_logical_core_count;
use crate::util::error::SysMonitorError;
use log::debug;
use std::{
collections::HashMap,
fs,
@ -48,7 +49,7 @@ pub fn get_system_info() -> SystemInfo {
}
#[derive(Debug, Clone, Copy)]
struct CpuTimes {
pub struct CpuTimes {
user: u64,
nice: u64,
system: u64,
@ -57,8 +58,6 @@ struct CpuTimes {
irq: u64,
softirq: u64,
steal: u64,
guest: u64,
guest_nice: u64,
}
impl CpuTimes {
@ -147,18 +146,6 @@ fn read_all_cpu_times() -> Result<HashMap<u32, CpuTimes>> {
parts[8]
))
})?,
guest: parts[9].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse guest time: {}",
parts[9]
))
})?,
guest_nice: parts[10].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse guest_nice time: {}",
parts[10]
))
})?,
};
cpu_times_map.insert(core_id, times);
}
@ -288,7 +275,7 @@ pub fn get_cpu_core_info(
None
} else {
let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32));
Some(usage.max(0.0).min(100.0)) // clamp between 0 and 100
Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100
}
};
@ -374,7 +361,7 @@ fn get_fallback_temperature(hw_path: &Path) -> Option<f32> {
pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> {
let initial_cpu_times = read_all_cpu_times()?;
thread::sleep(Duration::from_millis(250)); // Interval for CPU usage calculation
thread::sleep(Duration::from_millis(250)); // interval for CPU usage calculation
let final_cpu_times = read_all_cpu_times()?;
let num_cores = get_logical_core_count()
@ -412,11 +399,13 @@ pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo {
eprintln!("Warning: {e}");
0
});
let path = (0..core_count)
.map(|i| PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/")))
.find(|path| path.exists());
if let Some(test_path_buf) = path {
cpufreq_base_path_buf = test_path_buf;
for i in 0..core_count {
let test_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"));
if test_path.exists() {
cpufreq_base_path_buf = test_path;
break; // Exit the loop as soon as we find a valid path
}
}
}
@ -533,7 +522,7 @@ pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
}
}
} 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 online == 1 {
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)? {
let entry = entry?;
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 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::<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)
}
/// 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> {
let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?;
let parts: Vec<&str> = loadavg_str.split_whitespace().collect();

View file

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

View file

@ -1 +1,2 @@
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()
}