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:
commit
1eeb6d2d90
15 changed files with 992 additions and 161 deletions
29
README.md
29
README.md
|
@ -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
262
src/battery.rs
Normal 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) = ¤t_stop {
|
||||||
|
let restore_result = sysfs::write_sysfs_value(&stop_path, prev_stop);
|
||||||
|
if let Err(re) = restore_result {
|
||||||
|
warn!(
|
||||||
|
"Failed to restore previous stop threshold for battery '{}': {}. Battery may be in an inconsistent state.",
|
||||||
|
battery.name, re
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Restored previous stop threshold ({}) for battery '{}'",
|
||||||
|
prev_stop, battery.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.push(format!(
|
||||||
|
"Failed to set start threshold for {} battery '{}': {}",
|
||||||
|
battery.pattern.description, battery.name, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Err(e) = stop_result {
|
||||||
|
errors.push(format!(
|
||||||
|
"Failed to set stop threshold for {} battery '{}': {}",
|
||||||
|
battery.pattern.description, battery.name, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if success_count > 0 {
|
||||||
|
if !errors.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Partial success setting battery thresholds: {}",
|
||||||
|
errors.join("; ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ControlError::WriteError(format!(
|
||||||
|
"Failed to set charge thresholds on any battery: {}",
|
||||||
|
errors.join("; ")
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if a power supply entry is a battery
|
||||||
|
fn is_battery(path: &Path) -> Result<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
|
||||||
|
}
|
|
@ -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}");
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
312
src/cpu.rs
312
src/cpu.rs
|
@ -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() {
|
|
||||||
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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(&governor_path).map_err(|e| {
|
// Convert input governor to lowercase for case-insensitive comparison
|
||||||
if e.kind() == io::ErrorKind::PermissionDenied {
|
let governor_lower = governor.to_lowercase();
|
||||||
ControlError::PermissionDenied(format!(
|
|
||||||
"Permission denied reading from {}",
|
// Convert all available governors to lowercase for comparison
|
||||||
governor_path.display()
|
let governors_lower: Vec<String> = governors.iter().map(|g| g.to_lowercase()).collect();
|
||||||
))
|
|
||||||
} else {
|
// Check if the lowercase governor is in the lowercase list
|
||||||
ControlError::ReadError(format!(
|
Ok((governors_lower.contains(&governor_lower), governors))
|
||||||
"Failed to read from {}: {e}",
|
}
|
||||||
governor_path.display()
|
|
||||||
))
|
/// 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: Vec<String> = content
|
||||||
let governors = content
|
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.collect::<Vec<String>>();
|
.collect();
|
||||||
|
|
||||||
if governors.is_empty() {
|
if !governors.is_empty() {
|
||||||
return Err(ControlError::ParseError(
|
return Ok(governors);
|
||||||
"No available governors found".to_string(),
|
}
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<()> {
|
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
|
||||||
|
///
|
||||||
|
/// - [`ControlError::NotSupported`] if:
|
||||||
/// - The file `/sys/firmware/acpi/platform_profile_choices` does not exist.
|
/// - The file `/sys/firmware/acpi/platform_profile_choices` does not exist.
|
||||||
/// - The file `/sys/firmware/acpi/platform_profile_choices` is empty.
|
/// - 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>> {
|
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<()> {
|
||||||
|
|
|
@ -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)?;
|
||||||
|
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
93
src/main.rs
93
src/main.rs
|
@ -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 }) => {
|
||||||
|
// 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)
|
cpu::set_min_frequency(freq_mhz, core_id)
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
.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 }) => {
|
||||||
|
// 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)
|
cpu::set_max_frequency(freq_mhz, core_id)
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
.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::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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
132
src/monitor.rs
132
src/monitor.rs
|
@ -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();
|
||||||
|
|
|
@ -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}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod sysfs;
|
||||||
|
|
80
src/util/sysfs.rs
Normal file
80
src/util/sysfs.rs
Normal 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()
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue