mirror of
https://github.com/RGBCube/superfreq
synced 2025-08-01 19:37:45 +00:00
Compare commits
38 commits
3e235c089b
...
0358e0bece
Author | SHA1 | Date | |
---|---|---|---|
0358e0bece | |||
2dbc747868 | |||
6b4972864f | |||
15bcdd269c | |||
533825beb8 | |||
29944b7dbb | |||
1fe3ca3bfd | |||
3aec909d5d | |||
6caa4f7941 | |||
24fa53914d | |||
3212bc0ad5 | |||
c1a509328b | |||
dfa788009c | |||
fb5a891d42 | |||
542c41ccbe | |||
45a9fd4749 | |||
ec34526012 | |||
d5dbb36de4 | |||
67e2115588 | |||
3a4b5fb530 | |||
a862b0b456 | |||
a5151f475b | |||
923f759533 | |||
986e7e08b5 | |||
36e4bc05af | |||
5016825802 | |||
6349055c64 | |||
98bbf28f3d | |||
cc0cc23b0d | |||
c69aba87b6 | |||
5559d08f3e | |||
d61564d5f5 | |||
d1247c1570 | |||
bc343eefd9 | |||
8f3abd1ca3 | |||
a1502009d5 | |||
8764d3a2ac | |||
76370a7445 |
23 changed files with 2410 additions and 3644 deletions
84
Cargo.lock
generated
84
Cargo.lock
generated
|
@ -95,6 +95,16 @@ dependencies = [
|
|||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap-verbosity-flag"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.38"
|
||||
|
@ -131,6 +141,15 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.7"
|
||||
|
@ -141,6 +160,28 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
|
@ -220,6 +261,12 @@ version = "0.3.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
|
@ -230,6 +277,17 @@ dependencies = [
|
|||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.1",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
|
@ -323,7 +381,7 @@ version = "1.16.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.3.9",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
@ -453,7 +511,9 @@ version = "0.3.2"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"clap-verbosity-flag",
|
||||
"ctrlc",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"jiff",
|
||||
|
@ -462,6 +522,7 @@ dependencies = [
|
|||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -542,6 +603,18 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
@ -635,3 +708,12 @@ checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
|
|||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
dependencies = [
|
||||
"is-terminal",
|
||||
]
|
||||
|
|
|
@ -10,7 +10,7 @@ rust-version = "1.85"
|
|||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
dirs = "6.0"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
clap = { version = "4.0", features = ["derive", "env"] }
|
||||
num_cpus = "1.16"
|
||||
ctrlc = "3.4"
|
||||
log = "0.4"
|
||||
|
@ -18,3 +18,6 @@ env_logger = "0.11"
|
|||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
jiff = "0.2.13"
|
||||
clap-verbosity-flag = "3.0.2"
|
||||
yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] }
|
||||
derive_more = { version = "2.0.1", features = ["full"] }
|
||||
|
|
2
config.toml
Normal file
2
config.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
[[rule]]
|
||||
priority = 0
|
262
src/battery.rs
262
src/battery.rs
|
@ -1,262 +0,0 @@
|
|||
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::Validation(msg) => {
|
||||
ControlError::InvalidValueError(msg)
|
||||
}
|
||||
_ => ControlError::InvalidValueError(format!("Invalid battery threshold values: {e}")),
|
||||
})?;
|
||||
|
||||
let power_supply_path = Path::new("/sys/class/power_supply");
|
||||
if !power_supply_path.exists() {
|
||||
return Err(ControlError::NotSupported(
|
||||
"Power supply path not found, battery threshold control not supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// XXX: Skip checking directory writability since /sys is a virtual filesystem
|
||||
// Individual file writability will be checked by find_battery_with_threshold_support
|
||||
|
||||
let supported_batteries = find_supported_batteries(power_supply_path)?;
|
||||
if supported_batteries.is_empty() {
|
||||
return Err(ControlError::NotSupported(
|
||||
"No batteries with charge threshold control support found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
apply_thresholds_to_batteries(&supported_batteries, thresholds.start, thresholds.stop)
|
||||
}
|
||||
|
||||
/// Finds all batteries in the system that support threshold control
|
||||
fn find_supported_batteries(power_supply_path: &Path) -> Result<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
|
||||
}
|
265
src/cli/debug.rs
265
src/cli/debug.rs
|
@ -1,265 +0,0 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::cpu;
|
||||
use crate::monitor;
|
||||
use crate::util::error::AppError;
|
||||
use std::fs;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Prints comprehensive debug information about the system
|
||||
pub fn run_debug(config: &AppConfig) -> Result<(), AppError> {
|
||||
println!("=== SUPERFREQ DEBUG INFORMATION ===");
|
||||
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Current date and time
|
||||
println!("Timestamp: {}", jiff::Timestamp::now());
|
||||
|
||||
// Kernel information
|
||||
if let Ok(kernel_info) = get_kernel_info() {
|
||||
println!("Kernel Version: {kernel_info}");
|
||||
} else {
|
||||
println!("Kernel Version: Unable to determine");
|
||||
}
|
||||
|
||||
// System uptime
|
||||
if let Ok(uptime) = get_system_uptime() {
|
||||
println!(
|
||||
"System Uptime: {} hours, {} minutes",
|
||||
uptime.as_secs() / 3600,
|
||||
(uptime.as_secs() % 3600) / 60
|
||||
);
|
||||
} else {
|
||||
println!("System Uptime: Unable to determine");
|
||||
}
|
||||
|
||||
// Get system information
|
||||
match monitor::collect_system_report(config) {
|
||||
Ok(report) => {
|
||||
println!("\n--- SYSTEM INFORMATION ---");
|
||||
println!("CPU Model: {}", report.system_info.cpu_model);
|
||||
println!("Architecture: {}", report.system_info.architecture);
|
||||
println!(
|
||||
"Linux Distribution: {}",
|
||||
report.system_info.linux_distribution
|
||||
);
|
||||
|
||||
println!("\n--- CONFIGURATION ---");
|
||||
println!("Current Configuration: {config:#?}");
|
||||
|
||||
// Print important sysfs paths and whether they exist
|
||||
println!("\n--- SYSFS PATHS ---");
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/intel_pstate/no_turbo",
|
||||
"Intel P-State Turbo Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/cpufreq/boost",
|
||||
"Generic CPU Boost Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/amd_pstate/cpufreq/boost",
|
||||
"AMD P-State Boost Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/firmware/acpi/platform_profile",
|
||||
"ACPI Platform Profile Control",
|
||||
);
|
||||
check_and_print_sysfs_path("/sys/class/power_supply", "Power Supply Information");
|
||||
|
||||
println!("\n--- CPU INFORMATION ---");
|
||||
println!("Current Governor: {:?}", report.cpu_global.current_governor);
|
||||
println!(
|
||||
"Available Governors: {}",
|
||||
report.cpu_global.available_governors.join(", ")
|
||||
);
|
||||
println!("Turbo Status: {:?}", report.cpu_global.turbo_status);
|
||||
println!(
|
||||
"Energy Performance Preference (EPP): {:?}",
|
||||
report.cpu_global.epp
|
||||
);
|
||||
println!("Energy Performance Bias (EPB): {:?}", report.cpu_global.epb);
|
||||
|
||||
// Add governor override information
|
||||
if let Some(override_governor) = cpu::get_governor_override() {
|
||||
println!("Governor Override: {}", override_governor.trim());
|
||||
} else {
|
||||
println!("Governor Override: None");
|
||||
}
|
||||
|
||||
println!("\n--- PLATFORM PROFILE ---");
|
||||
println!(
|
||||
"Current Platform Profile: {:?}",
|
||||
report.cpu_global.platform_profile
|
||||
);
|
||||
match cpu::get_platform_profiles() {
|
||||
Ok(profiles) => println!("Available Platform Profiles: {}", profiles.join(", ")),
|
||||
Err(_) => println!("Available Platform Profiles: Not supported on this system"),
|
||||
}
|
||||
|
||||
println!("\n--- CPU CORES DETAIL ---");
|
||||
println!("Total CPU Cores: {}", report.cpu_cores.len());
|
||||
for core in &report.cpu_cores {
|
||||
println!("Core {}:", core.core_id);
|
||||
println!(
|
||||
" Current Frequency: {} MHz",
|
||||
core.current_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Min Frequency: {} MHz",
|
||||
core.min_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Max Frequency: {} MHz",
|
||||
core.max_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Usage: {}%",
|
||||
core.usage_percent
|
||||
.map_or_else(|| "N/A".to_string(), |u| format!("{u:.1}"))
|
||||
);
|
||||
println!(
|
||||
" Temperature: {}°C",
|
||||
core.temperature_celsius
|
||||
.map_or_else(|| "N/A".to_string(), |t| format!("{t:.1}"))
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n--- TEMPERATURE INFORMATION ---");
|
||||
println!(
|
||||
"Average CPU Temperature: {}",
|
||||
report.cpu_global.average_temperature_celsius.map_or_else(
|
||||
|| "N/A (CPU temperature sensor not detected)".to_string(),
|
||||
|t| format!("{t:.1}°C")
|
||||
)
|
||||
);
|
||||
|
||||
println!("\n--- BATTERY INFORMATION ---");
|
||||
if report.batteries.is_empty() {
|
||||
println!("No batteries found or all are ignored.");
|
||||
} else {
|
||||
for battery in &report.batteries {
|
||||
println!("Battery: {}", battery.name);
|
||||
println!(" AC Connected: {}", battery.ac_connected);
|
||||
println!(
|
||||
" Charging State: {}",
|
||||
battery.charging_state.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
" Capacity: {}%",
|
||||
battery
|
||||
.capacity_percent
|
||||
.map_or_else(|| "N/A".to_string(), |c| c.to_string())
|
||||
);
|
||||
println!(
|
||||
" Power Rate: {} W",
|
||||
battery
|
||||
.power_rate_watts
|
||||
.map_or_else(|| "N/A".to_string(), |p| format!("{p:.2}"))
|
||||
);
|
||||
println!(
|
||||
" Charge Start Threshold: {}",
|
||||
battery
|
||||
.charge_start_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
println!(
|
||||
" Charge Stop Threshold: {}",
|
||||
battery
|
||||
.charge_stop_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n--- SYSTEM LOAD ---");
|
||||
println!(
|
||||
"Load Average (1 min): {:.2}",
|
||||
report.system_load.load_avg_1min
|
||||
);
|
||||
println!(
|
||||
"Load Average (5 min): {:.2}",
|
||||
report.system_load.load_avg_5min
|
||||
);
|
||||
println!(
|
||||
"Load Average (15 min): {:.2}",
|
||||
report.system_load.load_avg_15min
|
||||
);
|
||||
|
||||
println!("\n--- DAEMON STATUS ---");
|
||||
// Simple check for daemon status - can be expanded later
|
||||
let daemon_status = fs::metadata("/var/run/superfreq.pid").is_ok();
|
||||
println!("Daemon Running: {daemon_status}");
|
||||
|
||||
// Check for systemd service status
|
||||
if let Ok(systemd_status) = is_systemd_service_active("superfreq") {
|
||||
println!("Systemd Service Active: {systemd_status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(AppError::Monitor(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get kernel version information
|
||||
fn get_kernel_info() -> Result<String, AppError> {
|
||||
let output = Command::new("uname")
|
||||
.arg("-r")
|
||||
.output()
|
||||
.map_err(AppError::Io)?;
|
||||
|
||||
let kernel_version = String::from_utf8(output.stdout)
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse kernel version: {e}")))?;
|
||||
Ok(kernel_version.trim().to_string())
|
||||
}
|
||||
|
||||
/// Get system uptime
|
||||
fn get_system_uptime() -> Result<Duration, AppError> {
|
||||
let uptime_str = fs::read_to_string("/proc/uptime").map_err(AppError::Io)?;
|
||||
let uptime_secs = uptime_str
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::Generic("Invalid format in /proc/uptime file".to_string()))?
|
||||
.parse::<f64>()
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse uptime from /proc/uptime: {e}")))?;
|
||||
|
||||
Ok(Duration::from_secs_f64(uptime_secs))
|
||||
}
|
||||
|
||||
/// Check if a sysfs path exists and print its status
|
||||
fn check_and_print_sysfs_path(path: &str, description: &str) {
|
||||
let exists = std::path::Path::new(path).exists();
|
||||
println!(
|
||||
"{}: {} ({})",
|
||||
description,
|
||||
path,
|
||||
if exists { "Exists" } else { "Not Found" }
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a systemd service is active
|
||||
fn is_systemd_service_active(service_name: &str) -> Result<bool, AppError> {
|
||||
let output = Command::new("systemctl")
|
||||
.arg("is-active")
|
||||
.arg(format!("{service_name}.service"))
|
||||
.stdout(Stdio::piped()) // capture stdout instead of letting it print
|
||||
.stderr(Stdio::null()) // redirect stderr to null
|
||||
.output()
|
||||
.map_err(AppError::Io)?;
|
||||
|
||||
// Check if the command executed successfully
|
||||
if !output.status.success() {
|
||||
// Command failed - service is either not found or not active
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Command executed successfully, now check the output content
|
||||
let status = String::from_utf8(output.stdout)
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse systemctl output: {e}")))?;
|
||||
|
||||
// Explicitly verify the output is "active"
|
||||
Ok(status.trim() == "active")
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
pub mod debug;
|
297
src/config.rs
Normal file
297
src/config.rs
Normal file
|
@ -0,0 +1,297 @@
|
|||
use std::{fs, path::Path};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{cpu, power_supply};
|
||||
|
||||
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
|
||||
*value == T::default()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default, rename_all = "kebab-case")]
|
||||
pub struct CpuDelta {
|
||||
/// The CPUs to apply the changes to. When unspecified, will be applied to all CPUs.
|
||||
#[arg(short = 'c', long = "for")]
|
||||
#[serde(rename = "for", skip_serializing_if = "is_default")]
|
||||
pub for_: Option<Vec<u32>>,
|
||||
|
||||
/// Set the CPU governor.
|
||||
#[arg(short = 'g', long)]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub governor: Option<String>, // TODO: Validate with clap for available governors.
|
||||
|
||||
/// Set CPU Energy Performance Preference (EPP). Short form: --epp.
|
||||
#[arg(short = 'p', long, alias = "epp")]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub energy_performance_preference: Option<String>, // TODO: Validate with clap for available governors.
|
||||
|
||||
/// Set CPU Energy Performance Bias (EPB). Short form: --epb.
|
||||
#[arg(short = 'b', long, alias = "epb")]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub energy_performance_bias: Option<String>, // TODO: Validate with clap for available governors.
|
||||
|
||||
/// Set minimum CPU frequency in MHz. Short form: --freq-min.
|
||||
#[arg(short = 'f', long, alias = "freq-min", value_parser = clap::value_parser!(u64).range(1..=10_000))]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub frequency_mhz_minimum: Option<u64>,
|
||||
|
||||
/// Set maximum CPU frequency in MHz. Short form: --freq-max.
|
||||
#[arg(short = 'F', long, alias = "freq-max", value_parser = clap::value_parser!(u64).range(1..=10_000))]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub frequency_mhz_maximum: Option<u64>,
|
||||
|
||||
/// Set turbo boost behaviour. Has to be for all CPUs.
|
||||
#[arg(short = 't', long, conflicts_with = "for_")]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub turbo: Option<bool>,
|
||||
}
|
||||
|
||||
impl CpuDelta {
|
||||
pub fn apply(&self) -> anyhow::Result<()> {
|
||||
let mut cpus = match &self.for_ {
|
||||
Some(numbers) => {
|
||||
let mut cpus = Vec::with_capacity(numbers.len());
|
||||
let cache = cpu::CpuRescanCache::default();
|
||||
|
||||
for &number in numbers {
|
||||
cpus.push(cpu::Cpu::new(number, &cache)?);
|
||||
}
|
||||
|
||||
cpus
|
||||
}
|
||||
None => cpu::Cpu::all().context("failed to get all CPUs and their information")?,
|
||||
};
|
||||
|
||||
for cpu in &mut cpus {
|
||||
if let Some(governor) = self.governor.as_ref() {
|
||||
cpu.set_governor(governor)?;
|
||||
}
|
||||
|
||||
if let Some(epp) = self.energy_performance_preference.as_ref() {
|
||||
cpu.set_epp(epp)?;
|
||||
}
|
||||
|
||||
if let Some(epb) = self.energy_performance_bias.as_ref() {
|
||||
cpu.set_epb(epb)?;
|
||||
}
|
||||
|
||||
if let Some(mhz_minimum) = self.frequency_mhz_minimum {
|
||||
cpu.set_frequency_mhz_minimum(mhz_minimum)?;
|
||||
}
|
||||
|
||||
if let Some(mhz_maximum) = self.frequency_mhz_maximum {
|
||||
cpu.set_frequency_mhz_maximum(mhz_maximum)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(turbo) = self.turbo {
|
||||
cpu::Cpu::set_turbo(turbo)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields, default, rename_all = "kebab-case")]
|
||||
pub struct PowerDelta {
|
||||
/// The power supplies to apply the changes to. When unspecified, will be applied to all power supplies.
|
||||
#[arg(short = 'p', long = "for")]
|
||||
#[serde(rename = "for", skip_serializing_if = "is_default")]
|
||||
pub for_: Option<Vec<String>>,
|
||||
|
||||
/// Set the percentage that the power supply has to drop under for charging to start. Short form: --charge-start.
|
||||
#[arg(short = 'c', long, alias = "charge-start", value_parser = clap::value_parser!(u8).range(0..=100))]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub charge_threshold_start: Option<u8>,
|
||||
|
||||
/// Set the percentage where charging will stop. Short form: --charge-end.
|
||||
#[arg(short = 'C', long, alias = "charge-end", value_parser = clap::value_parser!(u8).range(0..=100))]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub charge_threshold_end: Option<u8>,
|
||||
|
||||
/// Set ACPI platform profile. Has to be for all power supplies.
|
||||
#[arg(short = 'f', long, alias = "profile", conflicts_with = "for_")]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub platform_profile: Option<String>,
|
||||
}
|
||||
|
||||
impl PowerDelta {
|
||||
pub fn apply(&self) -> anyhow::Result<()> {
|
||||
let mut power_supplies = match &self.for_ {
|
||||
Some(names) => {
|
||||
let mut power_supplies = Vec::with_capacity(names.len());
|
||||
|
||||
for name in names {
|
||||
power_supplies.push(power_supply::PowerSupply::from_name(name.clone())?);
|
||||
}
|
||||
|
||||
power_supplies
|
||||
}
|
||||
|
||||
None => power_supply::PowerSupply::all()?
|
||||
.into_iter()
|
||||
.filter(|power_supply| power_supply.threshold_config.is_some())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
for power_supply in &mut power_supplies {
|
||||
if let Some(threshold_start) = self.charge_threshold_start {
|
||||
power_supply.set_charge_threshold_start(threshold_start as f64 / 100.0)?;
|
||||
}
|
||||
|
||||
if let Some(threshold_end) = self.charge_threshold_end {
|
||||
power_supply.set_charge_threshold_end(threshold_end as f64 / 100.0)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(platform_profile) = self.platform_profile.as_ref() {
|
||||
power_supply::PowerSupply::set_platform_profile(platform_profile)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(untagged, rename_all = "kebab-case")]
|
||||
pub enum Expression {
|
||||
#[serde(rename = "%cpu-usage")]
|
||||
CpuUsage,
|
||||
|
||||
#[serde(rename = "$cpu-usage-volatility")]
|
||||
CpuUsageVolatility,
|
||||
|
||||
#[serde(rename = "$cpu-temperature")]
|
||||
CpuTemperature,
|
||||
|
||||
#[serde(rename = "$cpu-temperature-volatility")]
|
||||
CpuTemperatureVolatility,
|
||||
|
||||
#[serde(rename = "$cpu-idle-seconds")]
|
||||
CpuIdleSeconds,
|
||||
|
||||
#[serde(rename = "%power-supply-charge")]
|
||||
PowerSupplyCharge,
|
||||
|
||||
#[serde(rename = "%power-supply-discharge-rate")]
|
||||
PowerSupplyDischargeRate,
|
||||
|
||||
#[serde(rename = "?charging")]
|
||||
Charging,
|
||||
#[serde(rename = "?on-battery")]
|
||||
OnBattery,
|
||||
|
||||
#[serde(rename = "#false")]
|
||||
False,
|
||||
|
||||
#[default]
|
||||
#[serde(rename = "#true")]
|
||||
True,
|
||||
|
||||
Number(f64),
|
||||
|
||||
Plus {
|
||||
value: Box<Expression>,
|
||||
plus: Box<Expression>,
|
||||
},
|
||||
Minus {
|
||||
value: Box<Expression>,
|
||||
minus: Box<Expression>,
|
||||
},
|
||||
Multiply {
|
||||
value: Box<Expression>,
|
||||
multiply: Box<Expression>,
|
||||
},
|
||||
Power {
|
||||
value: Box<Expression>,
|
||||
power: Box<Expression>,
|
||||
},
|
||||
Divide {
|
||||
value: Box<Expression>,
|
||||
divide: Box<Expression>,
|
||||
},
|
||||
|
||||
LessThan {
|
||||
value: Box<Expression>,
|
||||
is_less_than: Box<Expression>,
|
||||
},
|
||||
|
||||
MoreThan {
|
||||
value: Box<Expression>,
|
||||
is_more_than: Box<Expression>,
|
||||
},
|
||||
|
||||
Equal {
|
||||
value: Box<Expression>,
|
||||
is_equal: Box<Expression>,
|
||||
leeway: Box<Expression>,
|
||||
},
|
||||
|
||||
And {
|
||||
value: Box<Expression>,
|
||||
and: Box<Expression>,
|
||||
},
|
||||
All {
|
||||
all: Vec<Expression>,
|
||||
},
|
||||
|
||||
Or {
|
||||
value: Box<Expression>,
|
||||
or: Box<Expression>,
|
||||
},
|
||||
Any {
|
||||
any: Vec<Expression>,
|
||||
},
|
||||
|
||||
Not {
|
||||
not: Box<Expression>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
|
||||
pub struct Rule {
|
||||
priority: u8,
|
||||
|
||||
#[serde(default, rename = "if", skip_serializing_if = "is_default")]
|
||||
if_: Expression,
|
||||
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
cpu: CpuDelta,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
power: PowerDelta,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(default, rename_all = "kebab-case")]
|
||||
pub struct DaemonConfig {
|
||||
#[serde(rename = "rule")]
|
||||
rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
pub fn load_from(path: &Path) -> anyhow::Result<Self> {
|
||||
let contents = fs::read_to_string(path).with_context(|| {
|
||||
format!("failed to read config from '{path}'", path = path.display())
|
||||
})?;
|
||||
|
||||
let config: Self = toml::from_str(&contents).context("failed to parse config file")?;
|
||||
|
||||
{
|
||||
let mut priorities = Vec::with_capacity(config.rules.len());
|
||||
|
||||
for rule in &config.rules {
|
||||
if priorities.contains(&rule.priority) {
|
||||
bail!("each config rule must have a different priority")
|
||||
}
|
||||
|
||||
priorities.push(rule.priority);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
// Configuration loading functionality
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::types::{AppConfig, AppConfigToml, ConfigError, DaemonConfig, ProfileConfig};
|
||||
|
||||
/// The primary function to load application configuration from a specific path or from default locations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `specific_path` - If provided, only attempts to load from this path and errors if not found
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(AppConfig)` - Successfully loaded configuration
|
||||
/// * `Err(ConfigError)` - Error loading or parsing configuration
|
||||
pub fn load_config() -> Result<AppConfig, ConfigError> {
|
||||
load_config_from_path(None)
|
||||
}
|
||||
|
||||
/// Load configuration from a specific path or try default paths
|
||||
pub fn load_config_from_path(specific_path: Option<&str>) -> Result<AppConfig, ConfigError> {
|
||||
// If a specific path is provided, only try that one
|
||||
if let Some(path_str) = specific_path {
|
||||
let path = Path::new(path_str);
|
||||
if path.exists() {
|
||||
return load_and_parse_config(path);
|
||||
}
|
||||
return Err(ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Specified config file not found: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
|
||||
// Check for SUPERFREQ_CONFIG environment variable
|
||||
if let Ok(env_path) = std::env::var("SUPERFREQ_CONFIG") {
|
||||
let env_path = Path::new(&env_path);
|
||||
if env_path.exists() {
|
||||
println!(
|
||||
"Loading config from SUPERFREQ_CONFIG: {}",
|
||||
env_path.display()
|
||||
);
|
||||
return load_and_parse_config(env_path);
|
||||
}
|
||||
eprintln!(
|
||||
"Warning: Config file specified by SUPERFREQ_CONFIG not found: {}",
|
||||
env_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// System-wide paths
|
||||
let config_paths = vec![
|
||||
PathBuf::from("/etc/xdg/superfreq/config.toml"),
|
||||
PathBuf::from("/etc/superfreq.toml"),
|
||||
];
|
||||
|
||||
for path in config_paths {
|
||||
if path.exists() {
|
||||
println!("Loading config from: {}", path.display());
|
||||
match load_and_parse_config(&path) {
|
||||
Ok(config) => return Ok(config),
|
||||
Err(e) => {
|
||||
eprintln!("Error with config file {}: {}", path.display(), e);
|
||||
// Continue trying other files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No configuration file found or all failed to parse. Using default configuration.");
|
||||
// Construct default AppConfig by converting default AppConfigToml
|
||||
let default_toml_config = AppConfigToml::default();
|
||||
Ok(AppConfig {
|
||||
charger: ProfileConfig::from(default_toml_config.charger),
|
||||
battery: ProfileConfig::from(default_toml_config.battery),
|
||||
ignored_power_supplies: default_toml_config.ignored_power_supplies,
|
||||
daemon: DaemonConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load and parse a configuration file
|
||||
fn load_and_parse_config(path: &Path) -> Result<AppConfig, ConfigError> {
|
||||
let contents = fs::read_to_string(path).map_err(ConfigError::Io)?;
|
||||
|
||||
let toml_app_config = toml::from_str::<AppConfigToml>(&contents).map_err(ConfigError::Toml)?;
|
||||
|
||||
// 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(charger_profile),
|
||||
battery: ProfileConfig::from(battery_profile),
|
||||
ignored_power_supplies: toml_app_config.ignored_power_supplies,
|
||||
daemon: DaemonConfig {
|
||||
poll_interval_sec: toml_app_config.daemon.poll_interval_sec,
|
||||
adaptive_interval: toml_app_config.daemon.adaptive_interval,
|
||||
min_poll_interval_sec: toml_app_config.daemon.min_poll_interval_sec,
|
||||
max_poll_interval_sec: toml_app_config.daemon.max_poll_interval_sec,
|
||||
throttle_on_battery: toml_app_config.daemon.throttle_on_battery,
|
||||
log_level: toml_app_config.daemon.log_level,
|
||||
stats_file_path: toml_app_config.daemon.stats_file_path,
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
pub mod load;
|
||||
pub mod types;
|
||||
|
||||
pub use load::*;
|
||||
pub use types::*;
|
|
@ -1,312 +0,0 @@
|
|||
// Configuration types and structures for superfreq
|
||||
use crate::core::TurboSetting;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
/// Defines constant-returning functions used for default values.
|
||||
/// This hopefully reduces repetition since we have way too many default functions
|
||||
/// that just return constants.
|
||||
macro_rules! default_const {
|
||||
($name:ident, $type:ty, $value:expr) => {
|
||||
const fn $name() -> $type {
|
||||
$value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[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::Validation(
|
||||
"Stop threshold must be greater than 0%".to_string(),
|
||||
));
|
||||
}
|
||||
if start >= stop {
|
||||
return Err(ConfigError::Validation(format!(
|
||||
"Start threshold ({start}) must be less than stop threshold ({stop})"
|
||||
)));
|
||||
}
|
||||
if stop > 100 {
|
||||
return Err(ConfigError::Validation(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, Serialize, Debug, Clone)]
|
||||
pub struct ProfileConfig {
|
||||
pub governor: Option<String>,
|
||||
pub turbo: Option<TurboSetting>,
|
||||
pub epp: Option<String>, // Energy Performance Preference (EPP)
|
||||
pub epb: Option<String>, // Energy Performance Bias (EPB) - usually an integer, but string for flexibility from sysfs
|
||||
pub min_freq_mhz: Option<u32>,
|
||||
pub max_freq_mhz: Option<u32>,
|
||||
pub platform_profile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub turbo_auto_settings: TurboAutoSettings,
|
||||
#[serde(default)]
|
||||
pub enable_auto_turbo: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
}
|
||||
|
||||
impl Default for ProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
governor: Some("schedutil".to_string()), // common sensible default (?)
|
||||
turbo: Some(TurboSetting::Auto),
|
||||
epp: None, // defaults depend on governor and system
|
||||
epb: None, // defaults depend on governor and system
|
||||
min_freq_mhz: None, // no override
|
||||
max_freq_mhz: None, // no override
|
||||
platform_profile: None, // no override
|
||||
turbo_auto_settings: TurboAutoSettings::default(),
|
||||
enable_auto_turbo: default_enable_auto_turbo(),
|
||||
battery_charge_thresholds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub charger: ProfileConfig,
|
||||
#[serde(default)]
|
||||
pub battery: ProfileConfig,
|
||||
pub ignored_power_supplies: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfig,
|
||||
}
|
||||
|
||||
// Error type for config loading
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parsing error: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
#[error("Configuration validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
// Intermediate structs for TOML parsing
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ProfileConfigToml {
|
||||
pub governor: Option<String>,
|
||||
pub turbo: Option<String>, // "always", "auto", "never"
|
||||
pub epp: Option<String>,
|
||||
pub epb: Option<String>,
|
||||
pub min_freq_mhz: Option<u32>,
|
||||
pub max_freq_mhz: Option<u32>,
|
||||
pub platform_profile: Option<String>,
|
||||
pub turbo_auto_settings: Option<TurboAutoSettings>,
|
||||
#[serde(default = "default_enable_auto_turbo")]
|
||||
pub enable_auto_turbo: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct AppConfigToml {
|
||||
#[serde(default)]
|
||||
pub charger: ProfileConfigToml,
|
||||
#[serde(default)]
|
||||
pub battery: ProfileConfigToml,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
pub ignored_power_supplies: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfigToml,
|
||||
}
|
||||
|
||||
impl Default for ProfileConfigToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
governor: Some("schedutil".to_string()),
|
||||
turbo: Some("auto".to_string()),
|
||||
epp: None,
|
||||
epb: None,
|
||||
min_freq_mhz: None,
|
||||
max_freq_mhz: None,
|
||||
platform_profile: None,
|
||||
turbo_auto_settings: None,
|
||||
enable_auto_turbo: default_enable_auto_turbo(),
|
||||
battery_charge_thresholds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct TurboAutoSettings {
|
||||
#[serde(default = "default_load_threshold_high")]
|
||||
pub load_threshold_high: f32,
|
||||
#[serde(default = "default_load_threshold_low")]
|
||||
pub load_threshold_low: f32,
|
||||
#[serde(default = "default_temp_threshold_high")]
|
||||
pub temp_threshold_high: f32,
|
||||
/// Initial turbo boost state when no previous state exists.
|
||||
/// Set to `true` to start with turbo enabled, `false` to start with turbo disabled.
|
||||
/// This is only used at first launch or after a reset.
|
||||
#[serde(default = "default_initial_turbo_state")]
|
||||
pub initial_turbo_state: bool,
|
||||
}
|
||||
|
||||
// Default thresholds for Auto turbo mode
|
||||
pub const DEFAULT_LOAD_THRESHOLD_HIGH: f32 = 70.0; // enable turbo if load is above this
|
||||
pub const DEFAULT_LOAD_THRESHOLD_LOW: f32 = 30.0; // disable turbo if load is below this
|
||||
pub const DEFAULT_TEMP_THRESHOLD_HIGH: f32 = 75.0; // disable turbo if temperature is above this
|
||||
pub const DEFAULT_INITIAL_TURBO_STATE: bool = false; // by default, start with turbo disabled
|
||||
|
||||
default_const!(
|
||||
default_load_threshold_high,
|
||||
f32,
|
||||
DEFAULT_LOAD_THRESHOLD_HIGH
|
||||
);
|
||||
default_const!(default_load_threshold_low, f32, DEFAULT_LOAD_THRESHOLD_LOW);
|
||||
default_const!(
|
||||
default_temp_threshold_high,
|
||||
f32,
|
||||
DEFAULT_TEMP_THRESHOLD_HIGH
|
||||
);
|
||||
default_const!(
|
||||
default_initial_turbo_state,
|
||||
bool,
|
||||
DEFAULT_INITIAL_TURBO_STATE
|
||||
);
|
||||
|
||||
impl Default for TurboAutoSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
load_threshold_high: DEFAULT_LOAD_THRESHOLD_HIGH,
|
||||
load_threshold_low: DEFAULT_LOAD_THRESHOLD_LOW,
|
||||
temp_threshold_high: DEFAULT_TEMP_THRESHOLD_HIGH,
|
||||
initial_turbo_state: DEFAULT_INITIAL_TURBO_STATE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileConfigToml> for ProfileConfig {
|
||||
fn from(toml_config: ProfileConfigToml) -> Self {
|
||||
Self {
|
||||
governor: toml_config.governor,
|
||||
turbo: toml_config
|
||||
.turbo
|
||||
.and_then(|s| match s.to_lowercase().as_str() {
|
||||
"always" => Some(TurboSetting::Always),
|
||||
"auto" => Some(TurboSetting::Auto),
|
||||
"never" => Some(TurboSetting::Never),
|
||||
_ => None,
|
||||
}),
|
||||
epp: toml_config.epp,
|
||||
epb: toml_config.epb,
|
||||
min_freq_mhz: toml_config.min_freq_mhz,
|
||||
max_freq_mhz: toml_config.max_freq_mhz,
|
||||
platform_profile: toml_config.platform_profile,
|
||||
turbo_auto_settings: toml_config.turbo_auto_settings.unwrap_or_default(),
|
||||
enable_auto_turbo: toml_config.enable_auto_turbo,
|
||||
battery_charge_thresholds: toml_config.battery_charge_thresholds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DaemonConfig {
|
||||
#[serde(default = "default_poll_interval_sec")]
|
||||
pub poll_interval_sec: u64,
|
||||
#[serde(default = "default_adaptive_interval")]
|
||||
pub adaptive_interval: bool,
|
||||
#[serde(default = "default_min_poll_interval_sec")]
|
||||
pub min_poll_interval_sec: u64,
|
||||
#[serde(default = "default_max_poll_interval_sec")]
|
||||
pub max_poll_interval_sec: u64,
|
||||
#[serde(default = "default_throttle_on_battery")]
|
||||
pub throttle_on_battery: bool,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: LogLevel,
|
||||
#[serde(default = "default_stats_file_path")]
|
||||
pub stats_file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_sec: default_poll_interval_sec(),
|
||||
adaptive_interval: default_adaptive_interval(),
|
||||
min_poll_interval_sec: default_min_poll_interval_sec(),
|
||||
max_poll_interval_sec: default_max_poll_interval_sec(),
|
||||
throttle_on_battery: default_throttle_on_battery(),
|
||||
log_level: default_log_level(),
|
||||
stats_file_path: default_stats_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_const!(default_poll_interval_sec, u64, 5);
|
||||
default_const!(default_adaptive_interval, bool, false);
|
||||
default_const!(default_min_poll_interval_sec, u64, 1);
|
||||
default_const!(default_max_poll_interval_sec, u64, 30);
|
||||
default_const!(default_throttle_on_battery, bool, true);
|
||||
default_const!(default_log_level, LogLevel, LogLevel::Info);
|
||||
default_const!(default_stats_file_path, Option<String>, None);
|
||||
default_const!(default_enable_auto_turbo, bool, true);
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DaemonConfigToml {
|
||||
#[serde(default = "default_poll_interval_sec")]
|
||||
pub poll_interval_sec: u64,
|
||||
#[serde(default = "default_adaptive_interval")]
|
||||
pub adaptive_interval: bool,
|
||||
#[serde(default = "default_min_poll_interval_sec")]
|
||||
pub min_poll_interval_sec: u64,
|
||||
#[serde(default = "default_max_poll_interval_sec")]
|
||||
pub max_poll_interval_sec: u64,
|
||||
#[serde(default = "default_throttle_on_battery")]
|
||||
pub throttle_on_battery: bool,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: LogLevel,
|
||||
#[serde(default = "default_stats_file_path")]
|
||||
pub stats_file_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfigToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_sec: default_poll_interval_sec(),
|
||||
adaptive_interval: default_adaptive_interval(),
|
||||
min_poll_interval_sec: default_min_poll_interval_sec(),
|
||||
max_poll_interval_sec: default_max_poll_interval_sec(),
|
||||
throttle_on_battery: default_throttle_on_battery(),
|
||||
log_level: default_log_level(),
|
||||
stats_file_path: default_stats_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
42
src/core.rs
42
src/core.rs
|
@ -1,56 +1,18 @@
|
|||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[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
|
||||
Never, // turbo is forced off
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GovernorOverrideMode {
|
||||
Performance,
|
||||
Powersave,
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl fmt::Display for GovernorOverrideMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Performance => write!(f, "performance"),
|
||||
Self::Powersave => write!(f, "powersave"),
|
||||
Self::Reset => write!(f, "reset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SystemInfo {
|
||||
// Overall system details
|
||||
pub cpu_model: String,
|
||||
pub architecture: String,
|
||||
pub linux_distribution: String,
|
||||
}
|
||||
|
||||
pub struct CpuCoreInfo {
|
||||
// Per-core data
|
||||
pub core_id: u32,
|
||||
pub current_frequency_mhz: Option<u32>,
|
||||
pub min_frequency_mhz: Option<u32>,
|
||||
pub max_frequency_mhz: Option<u32>,
|
||||
pub usage_percent: Option<f32>,
|
||||
pub temperature_celsius: Option<f32>,
|
||||
}
|
||||
|
||||
pub struct CpuGlobalInfo {
|
||||
// System-wide CPU settings
|
||||
pub current_governor: Option<String>,
|
||||
pub available_governors: Vec<String>,
|
||||
pub turbo_status: Option<bool>, // true for enabled, false for disabled
|
||||
pub epp: Option<String>, // Energy Performance Preference
|
||||
pub epb: Option<String>, // Energy Performance Bias
|
||||
pub platform_profile: Option<String>,
|
||||
pub epp: Option<String>, // Energy Performance Preference
|
||||
pub epb: Option<String>, // Energy Performance Bias
|
||||
pub average_temperature_celsius: Option<f32>, // Average temperature across all cores
|
||||
}
|
||||
|
||||
|
|
1101
src/cpu.rs
1101
src/cpu.rs
File diff suppressed because it is too large
Load diff
841
src/daemon.rs
841
src/daemon.rs
|
@ -1,671 +1,262 @@
|
|||
use crate::config::{AppConfig, LogLevel};
|
||||
use crate::core::SystemReport;
|
||||
use crate::engine;
|
||||
use crate::monitor;
|
||||
use crate::util::error::{AppError, ControlError};
|
||||
use log::{LevelFilter, debug, error, info, warn};
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
ops,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Parameters for computing optimal polling interval
|
||||
struct IntervalParams {
|
||||
/// Base polling interval in seconds
|
||||
base_interval: u64,
|
||||
/// Minimum allowed polling interval in seconds
|
||||
min_interval: u64,
|
||||
/// Maximum allowed polling interval in seconds
|
||||
max_interval: u64,
|
||||
/// How rapidly CPU usage is changing
|
||||
cpu_volatility: f32,
|
||||
/// How rapidly temperature is changing
|
||||
temp_volatility: f32,
|
||||
/// Battery discharge rate in %/hour if available
|
||||
battery_discharge_rate: Option<f32>,
|
||||
/// Time since last detected user activity
|
||||
last_user_activity: Duration,
|
||||
/// Whether the system appears to be idle
|
||||
is_system_idle: bool,
|
||||
/// Whether the system is running on battery power
|
||||
on_battery: bool,
|
||||
}
|
||||
use anyhow::Context;
|
||||
|
||||
/// Calculate the idle time multiplier based on system idle duration
|
||||
use crate::config;
|
||||
|
||||
/// Calculate the idle time multiplier based on system idle time.
|
||||
///
|
||||
/// Returns a multiplier between 1.0 and 5.0 (capped):
|
||||
/// Returns a multiplier between 1.0 and 5.0:
|
||||
/// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0
|
||||
/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes))
|
||||
fn idle_multiplier(idle_secs: u64) -> f32 {
|
||||
if idle_secs == 0 {
|
||||
return 1.0; // No idle time, no multiplier effect
|
||||
}
|
||||
|
||||
let idle_factor = if idle_secs < 120 {
|
||||
// Less than 2 minutes (0 to 119 seconds)
|
||||
fn idle_multiplier(idle_for: Duration) -> f64 {
|
||||
let factor = match idle_for.as_secs() < 120 {
|
||||
// Less than 2 minutes.
|
||||
// Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s)
|
||||
1.0 + (idle_secs as f32) / 120.0
|
||||
} else {
|
||||
// 2 minutes (120 seconds) or more
|
||||
let idle_time_minutes = idle_secs / 60;
|
||||
true => (idle_for.as_secs() as f64) / 120.0,
|
||||
|
||||
// 2 minutes or more.
|
||||
// Logarithmic scaling: 1.0 + log2(minutes)
|
||||
1.0 + (idle_time_minutes as f32).log2().max(0.5)
|
||||
false => {
|
||||
let idle_minutes = idle_for.as_secs() as f64 / 60.0;
|
||||
idle_minutes.log2()
|
||||
}
|
||||
};
|
||||
|
||||
// Cap the multiplier to avoid excessive intervals
|
||||
idle_factor.min(5.0) // max factor of 5x
|
||||
// Clamp the multiplier to avoid excessive intervals.
|
||||
(1.0 + factor).clamp(1.0, 5.0)
|
||||
}
|
||||
|
||||
/// Calculate optimal polling interval based on system conditions and history
|
||||
///
|
||||
/// Returns Ok with the calculated interval, or Err if the configuration is invalid
|
||||
fn compute_new(
|
||||
params: &IntervalParams,
|
||||
system_history: &SystemHistory,
|
||||
) -> Result<u64, ControlError> {
|
||||
// Use the centralized validation function
|
||||
validate_poll_intervals(params.min_interval, params.max_interval)?;
|
||||
|
||||
// Start with base interval
|
||||
let mut adjusted_interval = params.base_interval;
|
||||
|
||||
// If we're on battery, we want to be more aggressive about saving power
|
||||
if params.on_battery {
|
||||
// Apply a multiplier based on battery discharge rate
|
||||
if let Some(discharge_rate) = params.battery_discharge_rate {
|
||||
if discharge_rate > 20.0 {
|
||||
// High discharge rate - increase polling interval significantly (3x)
|
||||
adjusted_interval = adjusted_interval.saturating_mul(3);
|
||||
} else if discharge_rate > 10.0 {
|
||||
// Moderate discharge - double polling interval (2x)
|
||||
adjusted_interval = adjusted_interval.saturating_mul(2);
|
||||
} else {
|
||||
// Low discharge rate - increase by 50% (multiply by 3/2)
|
||||
adjusted_interval = adjusted_interval.saturating_mul(3).saturating_div(2);
|
||||
}
|
||||
} else {
|
||||
// If we don't know discharge rate, use a conservative multiplier (2x)
|
||||
adjusted_interval = adjusted_interval.saturating_mul(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust for system idleness
|
||||
if params.is_system_idle {
|
||||
let idle_time_seconds = params.last_user_activity.as_secs();
|
||||
|
||||
// Apply adjustment only if the system has been idle for a non-zero duration
|
||||
if idle_time_seconds > 0 {
|
||||
let idle_factor = idle_multiplier(idle_time_seconds);
|
||||
|
||||
debug!(
|
||||
"System idle for {} seconds (approx. {} minutes), applying idle factor: {:.2}x",
|
||||
idle_time_seconds,
|
||||
(idle_time_seconds as f32 / 60.0).round(),
|
||||
idle_factor
|
||||
);
|
||||
|
||||
// Convert f32 multiplier to integer-safe math
|
||||
// Multiply by a large number first, then divide to maintain precision
|
||||
// Use 1000 as the scaling factor to preserve up to 3 decimal places
|
||||
let scaling_factor = 1000;
|
||||
let scaled_factor = (idle_factor * scaling_factor as f32) as u64;
|
||||
adjusted_interval = adjusted_interval
|
||||
.saturating_mul(scaled_factor)
|
||||
.saturating_div(scaling_factor);
|
||||
}
|
||||
// If idle_time_seconds is 0, no factor is applied by this block
|
||||
}
|
||||
|
||||
// Adjust for CPU/temperature volatility
|
||||
if params.cpu_volatility > 10.0 || params.temp_volatility > 2.0 {
|
||||
// For division by 2 (halving the interval), we can safely use integer division
|
||||
adjusted_interval = (adjusted_interval / 2).max(1);
|
||||
}
|
||||
|
||||
// Enforce a minimum of 1 second to prevent busy loops, regardless of params.min_interval
|
||||
let min_safe_interval = params.min_interval.max(1);
|
||||
let new_interval = adjusted_interval.clamp(min_safe_interval, params.max_interval);
|
||||
|
||||
// Blend the new interval with the cached value if available
|
||||
let blended_interval = if let Some(cached) = system_history.last_computed_interval {
|
||||
// Use a weighted average: 70% previous value, 30% new value
|
||||
// This smooths out drastic changes in polling frequency
|
||||
const PREVIOUS_VALUE_WEIGHT: u128 = 7; // 70%
|
||||
const NEW_VALUE_WEIGHT: u128 = 3; // 30%
|
||||
const TOTAL_WEIGHT: u128 = PREVIOUS_VALUE_WEIGHT + NEW_VALUE_WEIGHT; // 10
|
||||
|
||||
// XXX: Use u128 arithmetic to avoid overflow with large interval values
|
||||
let result = (u128::from(cached) * PREVIOUS_VALUE_WEIGHT
|
||||
+ u128::from(new_interval) * NEW_VALUE_WEIGHT)
|
||||
/ TOTAL_WEIGHT;
|
||||
|
||||
result as u64
|
||||
} else {
|
||||
new_interval
|
||||
};
|
||||
|
||||
// Blended result still needs to respect the configured bounds
|
||||
// Again enforce minimum of 1 second regardless of params.min_interval
|
||||
Ok(blended_interval.clamp(min_safe_interval, params.max_interval))
|
||||
}
|
||||
|
||||
/// Tracks historical system data for "advanced" adaptive polling
|
||||
#[derive(Debug)]
|
||||
struct SystemHistory {
|
||||
/// Last several CPU usage measurements
|
||||
cpu_usage_history: VecDeque<f32>,
|
||||
/// Last several temperature readings
|
||||
temperature_history: VecDeque<f32>,
|
||||
/// Time of last detected user activity
|
||||
struct Daemon {
|
||||
/// Last time when there was user activity.
|
||||
last_user_activity: Instant,
|
||||
/// Previous battery percentage (to calculate discharge rate)
|
||||
last_battery_percentage: Option<f32>,
|
||||
/// Timestamp of last battery reading
|
||||
last_battery_timestamp: Option<Instant>,
|
||||
/// Battery discharge rate (%/hour)
|
||||
battery_discharge_rate: Option<f32>,
|
||||
/// Time spent in each system state
|
||||
state_durations: std::collections::HashMap<SystemState, Duration>,
|
||||
/// Last time a state transition happened
|
||||
last_state_change: Instant,
|
||||
/// Current system state
|
||||
current_state: SystemState,
|
||||
/// Last computed optimal polling interval
|
||||
last_computed_interval: Option<u64>,
|
||||
|
||||
/// The last computed polling interval.
|
||||
last_polling_interval: Option<Duration>,
|
||||
|
||||
/// Whether if we are charging right now.
|
||||
charging: bool,
|
||||
|
||||
/// CPU usage and temperature log.
|
||||
cpu_log: VecDeque<CpuLog>,
|
||||
|
||||
/// Power supply status log.
|
||||
power_supply_log: VecDeque<PowerSupplyLog>,
|
||||
}
|
||||
|
||||
impl Default for SystemHistory {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu_usage_history: VecDeque::new(),
|
||||
temperature_history: VecDeque::new(),
|
||||
last_user_activity: Instant::now(),
|
||||
last_battery_percentage: None,
|
||||
last_battery_timestamp: None,
|
||||
battery_discharge_rate: None,
|
||||
state_durations: std::collections::HashMap::new(),
|
||||
last_state_change: Instant::now(),
|
||||
current_state: SystemState::default(),
|
||||
last_computed_interval: None,
|
||||
}
|
||||
}
|
||||
struct CpuLog {
|
||||
at: Instant,
|
||||
|
||||
/// CPU usage between 0-1, a percentage.
|
||||
usage: f64,
|
||||
|
||||
/// CPU temperature in celcius.
|
||||
temperature: f64,
|
||||
}
|
||||
|
||||
impl SystemHistory {
|
||||
/// Update system history with new report data
|
||||
fn update(&mut self, report: &SystemReport) {
|
||||
// Update CPU usage history
|
||||
if !report.cpu_cores.is_empty() {
|
||||
let mut total_usage: f32 = 0.0;
|
||||
let mut core_count: usize = 0;
|
||||
struct CpuVolatility {
|
||||
at: ops::Range<Instant>,
|
||||
|
||||
for core in &report.cpu_cores {
|
||||
if let Some(usage) = core.usage_percent {
|
||||
total_usage += usage;
|
||||
core_count += 1;
|
||||
}
|
||||
}
|
||||
usage: f64,
|
||||
|
||||
if core_count > 0 {
|
||||
let avg_usage = total_usage / core_count as f32;
|
||||
temperature: f64,
|
||||
}
|
||||
|
||||
// Keep only the last 5 measurements
|
||||
if self.cpu_usage_history.len() >= 5 {
|
||||
self.cpu_usage_history.pop_front();
|
||||
}
|
||||
self.cpu_usage_history.push_back(avg_usage);
|
||||
|
||||
// Update last_user_activity if CPU usage indicates activity
|
||||
// Consider significant CPU usage or sudden change as user activity
|
||||
if avg_usage > 20.0
|
||||
|| (self.cpu_usage_history.len() > 1
|
||||
&& (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2])
|
||||
.abs()
|
||||
> 15.0)
|
||||
{
|
||||
self.last_user_activity = Instant::now();
|
||||
debug!("User activity detected based on CPU usage");
|
||||
}
|
||||
}
|
||||
impl Daemon {
|
||||
fn cpu_volatility(&self) -> Option<CpuVolatility> {
|
||||
if self.cpu_log.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Update temperature history
|
||||
if let Some(temp) = report.cpu_global.average_temperature_celsius {
|
||||
if self.temperature_history.len() >= 5 {
|
||||
self.temperature_history.pop_front();
|
||||
}
|
||||
self.temperature_history.push_back(temp);
|
||||
let change_count = self.cpu_log.len() - 1;
|
||||
|
||||
// Significant temperature increase can indicate user activity
|
||||
if self.temperature_history.len() > 1 {
|
||||
let temp_change =
|
||||
temp - self.temperature_history[self.temperature_history.len() - 2];
|
||||
if temp_change > 5.0 {
|
||||
// 5°C rise in temperature
|
||||
self.last_user_activity = Instant::now();
|
||||
debug!("User activity detected based on temperature change");
|
||||
}
|
||||
}
|
||||
let mut usage_change_sum = 0.0;
|
||||
let mut temperature_change_sum = 0.0;
|
||||
|
||||
for index in 0..change_count {
|
||||
let usage_change = self.cpu_log[index + 1].usage - self.cpu_log[index].usage;
|
||||
usage_change_sum += usage_change.abs();
|
||||
|
||||
let temperature_change =
|
||||
self.cpu_log[index + 1].temperature - self.cpu_log[index].temperature;
|
||||
temperature_change_sum += temperature_change.abs();
|
||||
}
|
||||
|
||||
// Update battery discharge rate
|
||||
if let Some(battery) = report.batteries.first() {
|
||||
// Reset when we are charging or have just connected AC
|
||||
if battery.ac_connected {
|
||||
// Reset discharge tracking but continue updating the rest of
|
||||
// the history so we still detect activity/load changes on AC.
|
||||
self.battery_discharge_rate = None;
|
||||
self.last_battery_percentage = None;
|
||||
self.last_battery_timestamp = None;
|
||||
}
|
||||
Some(CpuVolatility {
|
||||
at: self.cpu_log.front().unwrap().at..self.cpu_log.back().unwrap().at,
|
||||
|
||||
if let Some(current_percentage) = battery.capacity_percent {
|
||||
let current_percent = f32::from(current_percentage);
|
||||
|
||||
if let (Some(last_percentage), Some(last_timestamp)) =
|
||||
(self.last_battery_percentage, self.last_battery_timestamp)
|
||||
{
|
||||
let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0;
|
||||
// Only calculate discharge rate if at least 30 seconds have passed
|
||||
// and we're not on AC power
|
||||
if elapsed_hours > 0.0083 && !battery.ac_connected {
|
||||
// 0.0083 hours = 30 seconds
|
||||
// Calculate discharge rate in percent per hour
|
||||
let percent_change = last_percentage - current_percent;
|
||||
if percent_change > 0.0 {
|
||||
// Only if battery is discharging
|
||||
let hourly_rate = percent_change / elapsed_hours;
|
||||
// Clamp the discharge rate to a reasonable maximum value (100%/hour)
|
||||
let clamped_rate = hourly_rate.min(100.0);
|
||||
self.battery_discharge_rate = Some(clamped_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_battery_percentage = Some(current_percent);
|
||||
self.last_battery_timestamp = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
// Update system state tracking
|
||||
let new_state = determine_system_state(report, self);
|
||||
if new_state != self.current_state {
|
||||
// Record time spent in previous state
|
||||
let time_in_state = self.last_state_change.elapsed();
|
||||
*self
|
||||
.state_durations
|
||||
.entry(self.current_state.clone())
|
||||
.or_insert(Duration::ZERO) += time_in_state;
|
||||
|
||||
// State changes (except to Idle) likely indicate user activity
|
||||
if new_state != SystemState::Idle && new_state != SystemState::LowLoad {
|
||||
self.last_user_activity = Instant::now();
|
||||
debug!("User activity detected based on system state change to {new_state:?}");
|
||||
}
|
||||
|
||||
// Update state
|
||||
self.current_state = new_state;
|
||||
self.last_state_change = Instant::now();
|
||||
}
|
||||
|
||||
// Check for significant load changes
|
||||
if report.system_load.load_avg_1min > 1.0 {
|
||||
self.last_user_activity = Instant::now();
|
||||
debug!("User activity detected based on system load");
|
||||
}
|
||||
usage: usage_change_sum / change_count as f64,
|
||||
temperature: temperature_change_sum / change_count as f64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate CPU usage volatility (how much it's changing)
|
||||
fn get_cpu_volatility(&self) -> f32 {
|
||||
if self.cpu_usage_history.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
fn is_cpu_idle(&self) -> bool {
|
||||
let recent_log_count = self
|
||||
.cpu_log
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60))
|
||||
.count();
|
||||
|
||||
let mut sum_of_changes = 0.0;
|
||||
for i in 1..self.cpu_usage_history.len() {
|
||||
sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs();
|
||||
}
|
||||
|
||||
sum_of_changes / (self.cpu_usage_history.len() - 1) as f32
|
||||
}
|
||||
|
||||
/// Calculate temperature volatility
|
||||
fn get_temperature_volatility(&self) -> f32 {
|
||||
if self.temperature_history.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut sum_of_changes = 0.0;
|
||||
for i in 1..self.temperature_history.len() {
|
||||
sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs();
|
||||
}
|
||||
|
||||
sum_of_changes / (self.temperature_history.len() - 1) as f32
|
||||
}
|
||||
|
||||
/// Determine if the system appears to be idle
|
||||
fn is_system_idle(&self) -> bool {
|
||||
if self.cpu_usage_history.is_empty() {
|
||||
if recent_log_count < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// System considered idle if the average CPU usage of last readings is below 10%
|
||||
let recent_avg =
|
||||
self.cpu_usage_history.iter().sum::<f32>() / self.cpu_usage_history.len() as f32;
|
||||
recent_avg < 10.0 && self.get_cpu_volatility() < 5.0
|
||||
}
|
||||
let recent_average = self
|
||||
.cpu_log
|
||||
.iter()
|
||||
.rev()
|
||||
.take(recent_log_count)
|
||||
.map(|log| log.usage)
|
||||
.sum::<f64>()
|
||||
/ recent_log_count as f64;
|
||||
|
||||
/// Calculate optimal polling interval based on system conditions
|
||||
fn calculate_optimal_interval(
|
||||
&self,
|
||||
config: &AppConfig,
|
||||
on_battery: bool,
|
||||
) -> Result<u64, ControlError> {
|
||||
let params = IntervalParams {
|
||||
base_interval: config.daemon.poll_interval_sec,
|
||||
min_interval: config.daemon.min_poll_interval_sec,
|
||||
max_interval: config.daemon.max_poll_interval_sec,
|
||||
cpu_volatility: self.get_cpu_volatility(),
|
||||
temp_volatility: self.get_temperature_volatility(),
|
||||
battery_discharge_rate: self.battery_discharge_rate,
|
||||
last_user_activity: self.last_user_activity.elapsed(),
|
||||
is_system_idle: self.is_system_idle(),
|
||||
on_battery,
|
||||
recent_average < 0.1
|
||||
&& self
|
||||
.cpu_volatility()
|
||||
.is_none_or(|volatility| volatility.usage < 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
struct PowerSupplyLog {
|
||||
at: Instant,
|
||||
|
||||
/// Charge 0-1, as a percentage.
|
||||
charge: f64,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Calculates the discharge rate, returns a number between 0 and 1.
|
||||
///
|
||||
/// The discharge rate is averaged per hour.
|
||||
/// So a return value of Some(0.3) means the battery has been
|
||||
/// discharging 30% per hour.
|
||||
fn power_supply_discharge_rate(&self) -> Option<f64> {
|
||||
let mut last_charge = None;
|
||||
|
||||
// A list of increasing charge percentages.
|
||||
let discharging: Vec<&PowerSupplyLog> = self
|
||||
.power_supply_log
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(move |log| {
|
||||
let Some(last_charge_value) = last_charge else {
|
||||
last_charge = Some(log.charge);
|
||||
return true;
|
||||
};
|
||||
|
||||
last_charge = Some(log.charge);
|
||||
|
||||
log.charge > last_charge_value
|
||||
})
|
||||
.collect();
|
||||
|
||||
if discharging.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Start of discharging. Has the most charge.
|
||||
let start = discharging.last().unwrap();
|
||||
// End of discharging, very close to now. Has the least charge.
|
||||
let end = discharging.first().unwrap();
|
||||
|
||||
let discharging_duration_seconds = (start.at - end.at).as_secs_f64();
|
||||
let discharging_duration_hours = discharging_duration_seconds / 60.0 / 60.0;
|
||||
let discharged = start.charge - end.charge;
|
||||
|
||||
Some(discharged / discharging_duration_hours)
|
||||
}
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
fn polling_interval(&mut self) -> Duration {
|
||||
let mut interval = Duration::from_secs(5);
|
||||
|
||||
// We are on battery, so we must be more conservative with our polling.
|
||||
if !self.charging {
|
||||
match self.power_supply_discharge_rate() {
|
||||
Some(discharge_rate) => {
|
||||
if discharge_rate > 0.2 {
|
||||
interval *= 3;
|
||||
} else if discharge_rate > 0.1 {
|
||||
interval *= 2;
|
||||
} else {
|
||||
// *= 1.5;
|
||||
interval /= 2;
|
||||
interval *= 3;
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't deterine the discharge rate, that means that
|
||||
// we were very recently started. Which is user activity.
|
||||
None => {
|
||||
interval *= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_cpu_idle() {
|
||||
let idle_for = self.last_user_activity.elapsed();
|
||||
|
||||
if idle_for > Duration::from_secs(30) {
|
||||
let factor = idle_multiplier(idle_for);
|
||||
|
||||
log::debug!(
|
||||
"system has been idle for {seconds} seconds (approx {minutes} minutes), applying idle factor: {factor:.2}x",
|
||||
seconds = idle_for.as_secs(),
|
||||
minutes = idle_for.as_secs() / 60,
|
||||
);
|
||||
|
||||
interval = Duration::from_secs_f64(interval.as_secs_f64() * factor);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(volatility) = self.cpu_volatility() {
|
||||
if volatility.usage > 0.1 || volatility.temperature > 0.02 {
|
||||
interval = (interval / 2).max(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
let interval = match self.last_polling_interval {
|
||||
Some(last_interval) => Duration::from_secs_f64(
|
||||
// 30% of current computed interval, 70% of last interval.
|
||||
interval.as_secs_f64() * 0.3 + last_interval.as_secs_f64() * 0.7,
|
||||
),
|
||||
|
||||
None => interval,
|
||||
};
|
||||
|
||||
compute_new(¶ms, self)
|
||||
let interval = Duration::from_secs_f64(interval.as_secs_f64().clamp(1.0, 30.0));
|
||||
|
||||
self.last_polling_interval = Some(interval);
|
||||
|
||||
interval
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that poll interval configuration is consistent
|
||||
/// Returns Ok if configuration is valid, Err with a descriptive message if invalid
|
||||
fn validate_poll_intervals(min_interval: u64, max_interval: u64) -> Result<(), ControlError> {
|
||||
if min_interval < 1 {
|
||||
return Err(ControlError::InvalidValueError(
|
||||
"min_interval must be ≥ 1".to_string(),
|
||||
));
|
||||
}
|
||||
if max_interval < 1 {
|
||||
return Err(ControlError::InvalidValueError(
|
||||
"max_interval must be ≥ 1".to_string(),
|
||||
));
|
||||
}
|
||||
if max_interval >= min_interval {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlError::InvalidValueError(format!(
|
||||
"Invalid interval configuration: max_interval ({max_interval}) is less than min_interval ({min_interval})"
|
||||
)))
|
||||
}
|
||||
}
|
||||
pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> {
|
||||
log::info!("starting daemon...");
|
||||
|
||||
/// Run the daemon
|
||||
pub fn run_daemon(config: AppConfig, verbose: bool) -> Result<(), AppError> {
|
||||
// Set effective log level based on config and verbose flag
|
||||
let effective_log_level = if verbose {
|
||||
LogLevel::Debug
|
||||
} else {
|
||||
config.daemon.log_level
|
||||
};
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Get the appropriate level filter
|
||||
let level_filter = match effective_log_level {
|
||||
LogLevel::Error => LevelFilter::Error,
|
||||
LogLevel::Warning => LevelFilter::Warn,
|
||||
LogLevel::Info => LevelFilter::Info,
|
||||
LogLevel::Debug => LevelFilter::Debug,
|
||||
};
|
||||
|
||||
// Update the log level filter if needed, without re-initializing the logger
|
||||
log::set_max_level(level_filter);
|
||||
|
||||
info!("Starting superfreq daemon...");
|
||||
|
||||
// Validate critical configuration values before proceeding
|
||||
if let Err(err) = validate_poll_intervals(
|
||||
config.daemon.min_poll_interval_sec,
|
||||
config.daemon.max_poll_interval_sec,
|
||||
) {
|
||||
return Err(AppError::Control(err));
|
||||
}
|
||||
|
||||
// Create a flag that will be set to true when a signal is received
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
// Set up signal handlers
|
||||
let cancelled_ = Arc::clone(&cancelled);
|
||||
ctrlc::set_handler(move || {
|
||||
info!("Received shutdown signal, exiting...");
|
||||
r.store(false, Ordering::SeqCst);
|
||||
log::info!("received shutdown signal");
|
||||
cancelled_.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.map_err(|e| AppError::Generic(format!("Error setting Ctrl-C handler: {e}")))?;
|
||||
.context("failed to set Ctrl-C handler")?;
|
||||
|
||||
info!(
|
||||
"Daemon initialized with poll interval: {}s",
|
||||
config.daemon.poll_interval_sec
|
||||
);
|
||||
while !cancelled.load(Ordering::SeqCst) {}
|
||||
|
||||
// Set up stats file if configured
|
||||
if let Some(stats_path) = &config.daemon.stats_file_path {
|
||||
info!("Stats will be written to: {stats_path}");
|
||||
}
|
||||
|
||||
// Variables for adaptive polling
|
||||
// Make sure that the poll interval is *never* zero to prevent a busy loop
|
||||
let mut current_poll_interval = config.daemon.poll_interval_sec.max(1);
|
||||
if config.daemon.poll_interval_sec == 0 {
|
||||
warn!("Poll interval is set to zero in config, using 1s minimum to prevent a busy loop");
|
||||
}
|
||||
let mut system_history = SystemHistory::default();
|
||||
|
||||
// Main loop
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let start_time = Instant::now();
|
||||
|
||||
match monitor::collect_system_report(&config) {
|
||||
Ok(report) => {
|
||||
debug!("Collected system report, applying settings...");
|
||||
|
||||
// Store the current state before updating history
|
||||
let previous_state = system_history.current_state.clone();
|
||||
|
||||
// Update system history with new data
|
||||
system_history.update(&report);
|
||||
|
||||
// Update the stats file if configured
|
||||
if let Some(stats_path) = &config.daemon.stats_file_path {
|
||||
if let Err(e) = write_stats_file(stats_path, &report) {
|
||||
error!("Failed to write stats file: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
match engine::determine_and_apply_settings(&report, &config, None) {
|
||||
Ok(()) => {
|
||||
debug!("Successfully applied system settings");
|
||||
|
||||
// If system state changed, log the new state
|
||||
if system_history.current_state != previous_state {
|
||||
info!(
|
||||
"System state changed to: {:?}",
|
||||
system_history.current_state
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error applying system settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're on battery
|
||||
let on_battery = !report.batteries.is_empty()
|
||||
&& report.batteries.first().is_some_and(|b| !b.ac_connected);
|
||||
|
||||
// Calculate optimal polling interval if adaptive polling is enabled
|
||||
if config.daemon.adaptive_interval {
|
||||
match system_history.calculate_optimal_interval(&config, on_battery) {
|
||||
Ok(optimal_interval) => {
|
||||
// Store the new interval
|
||||
system_history.last_computed_interval = Some(optimal_interval);
|
||||
|
||||
debug!("Recalculated optimal interval: {optimal_interval}s");
|
||||
|
||||
// Don't change the interval too dramatically at once
|
||||
match optimal_interval.cmp(¤t_poll_interval) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
current_poll_interval =
|
||||
(current_poll_interval + optimal_interval) / 2;
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
current_poll_interval = current_poll_interval
|
||||
- ((current_poll_interval - optimal_interval) / 2).max(1);
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
// No change needed when they're equal
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error and stop the daemon when an invalid configuration is detected
|
||||
error!("Critical configuration error: {e}");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that we respect the (user) configured min and max limits
|
||||
current_poll_interval = current_poll_interval.clamp(
|
||||
config.daemon.min_poll_interval_sec,
|
||||
config.daemon.max_poll_interval_sec,
|
||||
);
|
||||
|
||||
debug!("Adaptive polling: set interval to {current_poll_interval}s");
|
||||
} else {
|
||||
// If adaptive polling is disabled, still apply battery-saving adjustment
|
||||
if config.daemon.throttle_on_battery && on_battery {
|
||||
let battery_multiplier = 2; // poll half as often on battery
|
||||
|
||||
// We need to make sure `poll_interval_sec` is *at least* 1
|
||||
// before multiplying.
|
||||
let safe_interval = config.daemon.poll_interval_sec.max(1);
|
||||
current_poll_interval = (safe_interval * battery_multiplier)
|
||||
.min(config.daemon.max_poll_interval_sec);
|
||||
|
||||
debug!(
|
||||
"On battery power, increased poll interval to {current_poll_interval}s"
|
||||
);
|
||||
} else {
|
||||
// Use the configured poll interval
|
||||
current_poll_interval = config.daemon.poll_interval_sec.max(1);
|
||||
if config.daemon.poll_interval_sec == 0 {
|
||||
debug!("Using minimum poll interval of 1s instead of configured 0s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error collecting system report: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for the remaining time in the poll interval
|
||||
let elapsed = start_time.elapsed();
|
||||
let poll_duration = Duration::from_secs(current_poll_interval);
|
||||
if elapsed < poll_duration {
|
||||
let sleep_time = poll_duration - elapsed;
|
||||
debug!("Sleeping for {}s until next cycle", sleep_time.as_secs());
|
||||
std::thread::sleep(sleep_time);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
writeln!(file, "timestamp={:?}", report.timestamp)?;
|
||||
|
||||
// CPU info
|
||||
writeln!(file, "governor={:?}", report.cpu_global.current_governor)?;
|
||||
writeln!(file, "turbo={:?}", report.cpu_global.turbo_status)?;
|
||||
if let Some(temp) = report.cpu_global.average_temperature_celsius {
|
||||
writeln!(file, "cpu_temp={temp:.1}")?;
|
||||
}
|
||||
|
||||
// Battery info
|
||||
if !report.batteries.is_empty() {
|
||||
let battery = &report.batteries[0];
|
||||
writeln!(file, "ac_power={}", battery.ac_connected)?;
|
||||
if let Some(cap) = battery.capacity_percent {
|
||||
writeln!(file, "battery_percent={cap}")?;
|
||||
}
|
||||
}
|
||||
|
||||
// System load
|
||||
writeln!(file, "load_1m={:.2}", report.system_load.load_avg_1min)?;
|
||||
writeln!(file, "load_5m={:.2}", report.system_load.load_avg_5min)?;
|
||||
writeln!(file, "load_15m={:.2}", report.system_load.load_avg_15min)?;
|
||||
log::info!("exiting...");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simplified system state used for determining when to adjust polling interval
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)]
|
||||
enum SystemState {
|
||||
#[default]
|
||||
Unknown,
|
||||
OnAC,
|
||||
OnBattery,
|
||||
HighLoad,
|
||||
LowLoad,
|
||||
HighTemp,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// Determine the current system state for adaptive polling
|
||||
fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState {
|
||||
// Check power state first
|
||||
if !report.batteries.is_empty() {
|
||||
if let Some(battery) = report.batteries.first() {
|
||||
if battery.ac_connected {
|
||||
return SystemState::OnAC;
|
||||
}
|
||||
return SystemState::OnBattery;
|
||||
}
|
||||
}
|
||||
|
||||
// No batteries means desktop, so always AC
|
||||
if report.batteries.is_empty() {
|
||||
return SystemState::OnAC;
|
||||
}
|
||||
|
||||
// Check temperature
|
||||
if let Some(temp) = report.cpu_global.average_temperature_celsius {
|
||||
if temp > 80.0 {
|
||||
return SystemState::HighTemp;
|
||||
}
|
||||
}
|
||||
|
||||
// Check load first, as high load should take precedence over idle state
|
||||
let avg_load = report.system_load.load_avg_1min;
|
||||
if avg_load > 3.0 {
|
||||
return SystemState::HighLoad;
|
||||
}
|
||||
|
||||
// Check idle state only if we don't have high load
|
||||
if history.is_system_idle() {
|
||||
return SystemState::Idle;
|
||||
}
|
||||
|
||||
// Check for low load
|
||||
if avg_load < 0.5 {
|
||||
return SystemState::LowLoad;
|
||||
}
|
||||
|
||||
// Default case
|
||||
SystemState::Unknown
|
||||
}
|
||||
|
|
426
src/daemon_old.rs
Normal file
426
src/daemon_old.rs
Normal file
|
@ -0,0 +1,426 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::bail;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::SystemReport;
|
||||
use crate::engine;
|
||||
use crate::monitor;
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Tracks historical system data for "advanced" adaptive polling
|
||||
#[derive(Debug)]
|
||||
struct SystemHistory {
|
||||
/// Last several CPU usage measurements
|
||||
cpu_usage_history: VecDeque<f32>,
|
||||
/// Last several temperature readings
|
||||
temperature_history: VecDeque<f32>,
|
||||
/// Time of last detected user activity
|
||||
last_user_activity: Instant,
|
||||
/// Previous battery percentage (to calculate discharge rate)
|
||||
last_battery_percentage: Option<f32>,
|
||||
/// Timestamp of last battery reading
|
||||
last_battery_timestamp: Option<Instant>,
|
||||
/// Battery discharge rate (%/hour)
|
||||
battery_discharge_rate: Option<f32>,
|
||||
/// Time spent in each system state
|
||||
state_durations: std::collections::HashMap<SystemState, Duration>,
|
||||
/// Last time a state transition happened
|
||||
last_state_change: Instant,
|
||||
/// Current system state
|
||||
current_state: SystemState,
|
||||
/// Last computed optimal polling interval
|
||||
last_computed_interval: Option<u64>,
|
||||
}
|
||||
|
||||
impl SystemHistory {
|
||||
/// Update system history with new report data
|
||||
fn update(&mut self, report: &SystemReport) {
|
||||
// Update CPU usage history
|
||||
if !report.cpu_cores.is_empty() {
|
||||
let mut total_usage: f32 = 0.0;
|
||||
let mut core_count: usize = 0;
|
||||
|
||||
for core in &report.cpu_cores {
|
||||
if let Some(usage) = core.usage_percent {
|
||||
total_usage += usage;
|
||||
core_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if core_count > 0 {
|
||||
let avg_usage = total_usage / core_count as f32;
|
||||
|
||||
// Keep only the last 5 measurements
|
||||
if self.cpu_usage_history.len() >= 5 {
|
||||
self.cpu_usage_history.pop_front();
|
||||
}
|
||||
self.cpu_usage_history.push_back(avg_usage);
|
||||
|
||||
// Update last_user_activity if CPU usage indicates activity
|
||||
// Consider significant CPU usage or sudden change as user activity
|
||||
if avg_usage > 20.0
|
||||
|| (self.cpu_usage_history.len() > 1
|
||||
&& (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2])
|
||||
.abs()
|
||||
> 15.0)
|
||||
{
|
||||
self.last_user_activity = Instant::now();
|
||||
log::debug!("User activity detected based on CPU usage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update temperature history
|
||||
if let Some(temp) = report.cpu_global.average_temperature_celsius {
|
||||
if self.temperature_history.len() >= 5 {
|
||||
self.temperature_history.pop_front();
|
||||
}
|
||||
self.temperature_history.push_back(temp);
|
||||
|
||||
// Significant temperature increase can indicate user activity
|
||||
if self.temperature_history.len() > 1 {
|
||||
let temp_change =
|
||||
temp - self.temperature_history[self.temperature_history.len() - 2];
|
||||
if temp_change > 5.0 {
|
||||
// 5°C rise in temperature
|
||||
self.last_user_activity = Instant::now();
|
||||
log::debug!("User activity detected based on temperature change");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update battery discharge rate
|
||||
if let Some(battery) = report.batteries.first() {
|
||||
// Reset when we are charging or have just connected AC
|
||||
if battery.ac_connected {
|
||||
// Reset discharge tracking but continue updating the rest of
|
||||
// the history so we still detect activity/load changes on AC.
|
||||
self.battery_discharge_rate = None;
|
||||
self.last_battery_percentage = None;
|
||||
self.last_battery_timestamp = None;
|
||||
}
|
||||
|
||||
if let Some(current_percentage) = battery.capacity_percent {
|
||||
let current_percent = f32::from(current_percentage);
|
||||
|
||||
if let (Some(last_percentage), Some(last_timestamp)) =
|
||||
(self.last_battery_percentage, self.last_battery_timestamp)
|
||||
{
|
||||
let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0;
|
||||
// Only calculate discharge rate if at least 30 seconds have passed
|
||||
// and we're not on AC power
|
||||
if elapsed_hours > 0.0083 && !battery.ac_connected {
|
||||
// 0.0083 hours = 30 seconds
|
||||
// Calculate discharge rate in percent per hour
|
||||
let percent_change = last_percentage - current_percent;
|
||||
if percent_change > 0.0 {
|
||||
// Only if battery is discharging
|
||||
let hourly_rate = percent_change / elapsed_hours;
|
||||
// Clamp the discharge rate to a reasonable maximum value (100%/hour)
|
||||
let clamped_rate = hourly_rate.min(100.0);
|
||||
self.battery_discharge_rate = Some(clamped_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_battery_percentage = Some(current_percent);
|
||||
self.last_battery_timestamp = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
// Update system state tracking
|
||||
let new_state = determine_system_state(report, self);
|
||||
if new_state != self.current_state {
|
||||
// Record time spent in previous state
|
||||
let time_in_state = self.last_state_change.elapsed();
|
||||
*self
|
||||
.state_durations
|
||||
.entry(self.current_state.clone())
|
||||
.or_insert(Duration::ZERO) += time_in_state;
|
||||
|
||||
// State changes (except to Idle) likely indicate user activity
|
||||
if new_state != SystemState::Idle && new_state != SystemState::LowLoad {
|
||||
self.last_user_activity = Instant::now();
|
||||
log::debug!("User activity detected based on system state change to {new_state:?}");
|
||||
}
|
||||
|
||||
// Update state
|
||||
self.current_state = new_state;
|
||||
self.last_state_change = Instant::now();
|
||||
}
|
||||
|
||||
// Check for significant load changes
|
||||
if report.system_load.load_avg_1min > 1.0 {
|
||||
self.last_user_activity = Instant::now();
|
||||
log::debug!("User activity detected based on system load");
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate CPU usage volatility (how much it's changing)
|
||||
fn get_cpu_volatility(&self) -> f32 {
|
||||
if self.cpu_usage_history.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut sum_of_changes = 0.0;
|
||||
for i in 1..self.cpu_usage_history.len() {
|
||||
sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs();
|
||||
}
|
||||
|
||||
sum_of_changes / (self.cpu_usage_history.len() - 1) as f32
|
||||
}
|
||||
|
||||
/// Calculate temperature volatility
|
||||
fn get_temperature_volatility(&self) -> f32 {
|
||||
if self.temperature_history.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut sum_of_changes = 0.0;
|
||||
for i in 1..self.temperature_history.len() {
|
||||
sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs();
|
||||
}
|
||||
|
||||
sum_of_changes / (self.temperature_history.len() - 1) as f32
|
||||
}
|
||||
|
||||
/// Determine if the system appears to be idle
|
||||
fn is_system_idle(&self) -> bool {
|
||||
if self.cpu_usage_history.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// System considered idle if the average CPU usage of last readings is below 10%
|
||||
let recent_avg =
|
||||
self.cpu_usage_history.iter().sum::<f32>() / self.cpu_usage_history.len() as f32;
|
||||
recent_avg < 10.0 && self.get_cpu_volatility() < 5.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the daemon
|
||||
pub fn run_daemon(config: AppConfig) -> anyhow::Result<()> {
|
||||
log::info!("Starting superfreq daemon...");
|
||||
|
||||
// Validate critical configuration values before proceeding
|
||||
validate_poll_intervals(
|
||||
config.daemon.min_poll_interval_sec,
|
||||
config.daemon.max_poll_interval_sec,
|
||||
)?;
|
||||
|
||||
// Create a flag that will be set to true when a signal is received
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
|
||||
// Set up signal handlers
|
||||
ctrlc::set_handler(move || {
|
||||
log::info!("Received shutdown signal, exiting...");
|
||||
r.store(false, Ordering::SeqCst);
|
||||
})
|
||||
.context("failed to set Ctrl-C handler")?;
|
||||
|
||||
log::info!(
|
||||
"Daemon initialized with poll interval: {}s",
|
||||
config.daemon.poll_interval_sec
|
||||
);
|
||||
|
||||
// Set up stats file if configured
|
||||
if let Some(stats_path) = &config.daemon.stats_file_path {
|
||||
log::info!("Stats will be written to: {stats_path}");
|
||||
}
|
||||
|
||||
// Variables for adaptive polling
|
||||
// Make sure that the poll interval is *never* zero to prevent a busy loop
|
||||
let mut current_poll_interval = config.daemon.poll_interval_sec.max(1);
|
||||
if config.daemon.poll_interval_sec == 0 {
|
||||
log::warn!(
|
||||
"Poll interval is set to zero in config, using 1s minimum to prevent a busy loop"
|
||||
);
|
||||
}
|
||||
let mut system_history = SystemHistory::default();
|
||||
|
||||
// Main loop
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let start_time = Instant::now();
|
||||
|
||||
match monitor::collect_system_report(&config) {
|
||||
Ok(report) => {
|
||||
log::debug!("Collected system report, applying settings...");
|
||||
|
||||
// Store the current state before updating history
|
||||
let previous_state = system_history.current_state.clone();
|
||||
|
||||
// Update system history with new data
|
||||
system_history.update(&report);
|
||||
|
||||
// Update the stats file if configured
|
||||
if let Some(stats_path) = &config.daemon.stats_file_path {
|
||||
if let Err(e) = write_stats_file(stats_path, &report) {
|
||||
log::error!("Failed to write stats file: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
match engine::determine_and_apply_settings(&report, &config, None) {
|
||||
Ok(()) => {
|
||||
log::debug!("Successfully applied system settings");
|
||||
|
||||
// If system state changed, log the new state
|
||||
if system_history.current_state != previous_state {
|
||||
log::info!(
|
||||
"System state changed to: {:?}",
|
||||
system_history.current_state
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error applying system settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're on battery
|
||||
let on_battery = !report.batteries.is_empty()
|
||||
&& report.batteries.first().is_some_and(|b| !b.ac_connected);
|
||||
|
||||
// Calculate optimal polling interval if adaptive polling is enabled
|
||||
if config.daemon.adaptive_interval {
|
||||
match system_history.calculate_optimal_interval(&config, on_battery) {
|
||||
Ok(optimal_interval) => {
|
||||
// Store the new interval
|
||||
system_history.last_computed_interval = Some(optimal_interval);
|
||||
|
||||
log::debug!("Recalculated optimal interval: {optimal_interval}s");
|
||||
|
||||
// Don't change the interval too dramatically at once
|
||||
match optimal_interval.cmp(¤t_poll_interval) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
current_poll_interval =
|
||||
(current_poll_interval + optimal_interval) / 2;
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
current_poll_interval = current_poll_interval
|
||||
- ((current_poll_interval - optimal_interval) / 2).max(1);
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
// No change needed when they're equal
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error and stop the daemon when an invalid configuration is detected
|
||||
log::error!("Critical configuration error: {e}");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that we respect the (user) configured min and max limits
|
||||
current_poll_interval = current_poll_interval.clamp(
|
||||
config.daemon.min_poll_interval_sec,
|
||||
config.daemon.max_poll_interval_sec,
|
||||
);
|
||||
|
||||
log::debug!("Adaptive polling: set interval to {current_poll_interval}s");
|
||||
} else {
|
||||
// If adaptive polling is disabled, still apply battery-saving adjustment
|
||||
if config.daemon.throttle_on_battery && on_battery {
|
||||
let battery_multiplier = 2; // poll half as often on battery
|
||||
|
||||
// We need to make sure `poll_interval_sec` is *at least* 1
|
||||
// before multiplying.
|
||||
let safe_interval = config.daemon.poll_interval_sec.max(1);
|
||||
current_poll_interval = (safe_interval * battery_multiplier)
|
||||
.min(config.daemon.max_poll_interval_sec);
|
||||
|
||||
log::debug!(
|
||||
"On battery power, increased poll interval to {current_poll_interval}s"
|
||||
);
|
||||
} else {
|
||||
// Use the configured poll interval
|
||||
current_poll_interval = config.daemon.poll_interval_sec.max(1);
|
||||
if config.daemon.poll_interval_sec == 0 {
|
||||
log::debug!(
|
||||
"Using minimum poll interval of 1s instead of configured 0s"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error collecting system report: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for the remaining time in the poll interval
|
||||
let elapsed = start_time.elapsed();
|
||||
let poll_duration = Duration::from_secs(current_poll_interval);
|
||||
if elapsed < poll_duration {
|
||||
let sleep_time = poll_duration - elapsed;
|
||||
log::debug!("Sleeping for {}s until next cycle", sleep_time.as_secs());
|
||||
std::thread::sleep(sleep_time);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simplified system state used for determining when to adjust polling interval
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)]
|
||||
enum SystemState {
|
||||
#[default]
|
||||
Unknown,
|
||||
OnAC,
|
||||
OnBattery,
|
||||
HighLoad,
|
||||
LowLoad,
|
||||
HighTemp,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// Determine the current system state for adaptive polling
|
||||
fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState {
|
||||
// Check power state first
|
||||
if !report.batteries.is_empty() {
|
||||
if let Some(battery) = report.batteries.first() {
|
||||
if battery.ac_connected {
|
||||
return SystemState::OnAC;
|
||||
}
|
||||
return SystemState::OnBattery;
|
||||
}
|
||||
}
|
||||
|
||||
// No batteries means desktop, so always AC
|
||||
if report.batteries.is_empty() {
|
||||
return SystemState::OnAC;
|
||||
}
|
||||
|
||||
// Check temperature
|
||||
if let Some(temp) = report.cpu_global.average_temperature_celsius {
|
||||
if temp > 80.0 {
|
||||
return SystemState::HighTemp;
|
||||
}
|
||||
}
|
||||
|
||||
// Check load first, as high load should take precedence over idle state
|
||||
let avg_load = report.system_load.load_avg_1min;
|
||||
if avg_load > 3.0 {
|
||||
return SystemState::HighLoad;
|
||||
}
|
||||
|
||||
// Check idle state only if we don't have high load
|
||||
if history.is_system_idle() {
|
||||
return SystemState::Idle;
|
||||
}
|
||||
|
||||
// Check for low load
|
||||
if avg_load < 0.5 {
|
||||
return SystemState::LowLoad;
|
||||
}
|
||||
|
||||
// Default case
|
||||
SystemState::Unknown
|
||||
}
|
133
src/engine.rs
133
src/engine.rs
|
@ -1,9 +1,7 @@
|
|||
use crate::battery;
|
||||
use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings};
|
||||
use crate::core::{OperationalMode, SystemReport, TurboSetting};
|
||||
use crate::core::{OperationalMode, SystemReport};
|
||||
use crate::cpu::{self};
|
||||
use crate::util::error::{ControlError, EngineError};
|
||||
use log::{debug, info, warn};
|
||||
use crate::power_supply;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
@ -120,30 +118,14 @@ impl TurboHysteresis {
|
|||
/// 1. Try to apply a feature setting
|
||||
/// 2. If not supported, log a warning and continue
|
||||
/// 3. If other error, propagate the error
|
||||
fn try_apply_feature<F, T>(
|
||||
fn try_apply_feature<F: FnOnce() -> anyhow::Result<()>, T>(
|
||||
feature_name: &str,
|
||||
value_description: &str,
|
||||
apply_fn: F,
|
||||
) -> Result<(), EngineError>
|
||||
where
|
||||
F: FnOnce() -> Result<T, ControlError>,
|
||||
{
|
||||
info!("Setting {feature_name} to '{value_description}'");
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Setting {feature_name} to '{value_description}'");
|
||||
|
||||
match apply_fn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
if matches!(e, ControlError::NotSupported(_)) {
|
||||
warn!(
|
||||
"{feature_name} setting is not supported on this system. Skipping {feature_name} configuration."
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
// Propagate all other errors, including InvalidValueError
|
||||
Err(EngineError::ControlError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_fn()
|
||||
}
|
||||
|
||||
/// Determines the appropriate CPU profile based on power status or forced mode,
|
||||
|
@ -152,19 +134,19 @@ pub fn determine_and_apply_settings(
|
|||
report: &SystemReport,
|
||||
config: &AppConfig,
|
||||
force_mode: Option<OperationalMode>,
|
||||
) -> Result<(), EngineError> {
|
||||
// First, check if there's a governor override set
|
||||
if let Some(override_governor) = cpu::get_governor_override() {
|
||||
info!(
|
||||
"Governor override is active: '{}'. Setting governor.",
|
||||
override_governor.trim()
|
||||
);
|
||||
) -> anyhow::Result<()> {
|
||||
// // First, check if there's a governor override set
|
||||
// if let Some(override_governor) = cpu::get_governor_override() {
|
||||
// log::info!(
|
||||
// "Governor override is active: '{}'. Setting governor.",
|
||||
// override_governor.trim()
|
||||
// );
|
||||
|
||||
// Apply the override governor setting
|
||||
try_apply_feature("override governor", override_governor.trim(), || {
|
||||
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)
|
||||
// })?;
|
||||
// }
|
||||
|
||||
// Determine AC/Battery status once, early in the function
|
||||
// For desktops (no batteries), we should always use the AC power profile
|
||||
|
@ -182,52 +164,46 @@ pub fn determine_and_apply_settings(
|
|||
if let Some(mode) = force_mode {
|
||||
match mode {
|
||||
OperationalMode::Powersave => {
|
||||
info!("Forced Powersave mode selected. Applying 'battery' profile.");
|
||||
log::info!("Forced Powersave mode selected. Applying 'battery' profile.");
|
||||
selected_profile_config = &config.battery;
|
||||
}
|
||||
OperationalMode::Performance => {
|
||||
info!("Forced Performance mode selected. Applying 'charger' profile.");
|
||||
log::info!("Forced Performance mode selected. Applying 'charger' profile.");
|
||||
selected_profile_config = &config.charger;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use the previously computed on_ac_power value
|
||||
if on_ac_power {
|
||||
info!("On AC power, selecting Charger profile.");
|
||||
log::info!("On AC power, selecting Charger profile.");
|
||||
selected_profile_config = &config.charger;
|
||||
} else {
|
||||
info!("On Battery power, selecting Battery profile.");
|
||||
log::info!("On Battery power, selecting Battery profile.");
|
||||
selected_profile_config = &config.battery;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply settings from selected_profile_config
|
||||
if let Some(governor) = &selected_profile_config.governor {
|
||||
info!("Setting governor to '{governor}'");
|
||||
// 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(_))
|
||||
|| matches!(e, ControlError::NotSupported(_))
|
||||
{
|
||||
warn!(
|
||||
"Configured governor '{governor}' is not available on this system. Skipping."
|
||||
);
|
||||
} else {
|
||||
return Err(e.into());
|
||||
log::info!("Setting governor to '{governor}'");
|
||||
for cpu in cpu::Cpu::all()? {
|
||||
// Let set_governor handle the validation
|
||||
if let Err(error) = cpu.set_governor(governor) {
|
||||
// If the governor is not available, log a warning
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(turbo_setting) = selected_profile_config.turbo {
|
||||
info!("Setting turbo to '{turbo_setting:?}'");
|
||||
log::info!("Setting turbo to '{turbo_setting:?}'");
|
||||
match turbo_setting {
|
||||
TurboSetting::Auto => {
|
||||
if selected_profile_config.enable_auto_turbo {
|
||||
debug!("Managing turbo in auto mode based on system conditions");
|
||||
log::debug!("Managing turbo in auto mode based on system conditions");
|
||||
manage_auto_turbo(report, selected_profile_config, on_ac_power)?;
|
||||
} else {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Superfreq's dynamic turbo management is disabled by configuration. Ensuring system uses its default behavior for automatic turbo control."
|
||||
);
|
||||
// Make sure the system is set to its default automatic turbo mode.
|
||||
|
@ -255,13 +231,13 @@ pub fn determine_and_apply_settings(
|
|||
|
||||
if let Some(min_freq) = selected_profile_config.min_freq_mhz {
|
||||
try_apply_feature("min frequency", &format!("{min_freq} MHz"), || {
|
||||
cpu::set_min_frequency(min_freq, None)
|
||||
cpu::set_frequency_minimum(min_freq, None)
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(max_freq) = selected_profile_config.max_freq_mhz {
|
||||
try_apply_feature("max frequency", &format!("{max_freq} MHz"), || {
|
||||
cpu::set_max_frequency(max_freq, None)
|
||||
cpu::set_frequency_maximum(max_freq, None)
|
||||
})?;
|
||||
}
|
||||
|
||||
|
@ -277,19 +253,19 @@ pub fn determine_and_apply_settings(
|
|||
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}"),
|
||||
log::info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%");
|
||||
match power_supply::set_battery_charge_thresholds(start_threshold, stop_threshold) {
|
||||
Ok(()) => log::debug!("Battery charge thresholds set successfully"),
|
||||
Err(e) => log::warn!("Failed to set battery charge thresholds: {e}"),
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
log::warn!(
|
||||
"Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Profile settings applied successfully.");
|
||||
log::debug!("Profile settings applied successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -298,7 +274,7 @@ fn manage_auto_turbo(
|
|||
report: &SystemReport,
|
||||
config: &ProfileConfig,
|
||||
on_ac_power: bool,
|
||||
) -> Result<(), EngineError> {
|
||||
) -> anyhow::Result<()> {
|
||||
// Get the auto turbo settings from the config
|
||||
let turbo_settings = &config.turbo_auto_settings;
|
||||
|
||||
|
@ -346,27 +322,30 @@ fn manage_auto_turbo(
|
|||
let enable_turbo = match (cpu_temp, avg_cpu_usage, previous_turbo_enabled) {
|
||||
// If temperature is too high, disable turbo regardless of load
|
||||
(Some(temp), _, _) if temp >= turbo_settings.temp_threshold_high => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)",
|
||||
temp, turbo_settings.temp_threshold_high
|
||||
temp,
|
||||
turbo_settings.temp_threshold_high
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
// If load is high enough, enable turbo (unless temp already caused it to disable)
|
||||
(_, Some(usage), _) if usage >= turbo_settings.load_threshold_high => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)",
|
||||
usage, turbo_settings.load_threshold_high
|
||||
usage,
|
||||
turbo_settings.load_threshold_high
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
// If load is low, disable turbo
|
||||
(_, Some(usage), _) if usage <= turbo_settings.load_threshold_low => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)",
|
||||
usage, turbo_settings.load_threshold_low
|
||||
usage,
|
||||
turbo_settings.load_threshold_low
|
||||
);
|
||||
false
|
||||
}
|
||||
|
@ -376,7 +355,7 @@ fn manage_auto_turbo(
|
|||
if usage > turbo_settings.load_threshold_low
|
||||
&& usage < turbo_settings.load_threshold_high =>
|
||||
{
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to intermediate load ({:.1}%)",
|
||||
if prev_state { "enabled" } else { "disabled" },
|
||||
usage
|
||||
|
@ -386,7 +365,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// When CPU load data is present but temperature is missing, use the same hysteresis logic
|
||||
(None, Some(usage), prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to missing temperature data (load: {:.1}%)",
|
||||
if prev_state { "enabled" } else { "disabled" },
|
||||
usage
|
||||
|
@ -396,7 +375,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// When all metrics are missing, maintain the previous state
|
||||
(None, None, prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to missing all CPU metrics",
|
||||
if prev_state { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -405,7 +384,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// Any other cases with partial metrics, maintain previous state for stability
|
||||
(_, _, prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to incomplete CPU metrics",
|
||||
if prev_state { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -429,7 +408,7 @@ fn manage_auto_turbo(
|
|||
TurboSetting::Never
|
||||
};
|
||||
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Applying turbo change from {} to {}",
|
||||
if previous_turbo_enabled {
|
||||
"enabled"
|
||||
|
@ -441,7 +420,7 @@ fn manage_auto_turbo(
|
|||
|
||||
match cpu::set_turbo(turbo_setting) {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Auto Turbo: Successfully set turbo to {}",
|
||||
if enable_turbo { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -450,7 +429,7 @@ fn manage_auto_turbo(
|
|||
Err(e) => Err(EngineError::ControlError(e)),
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Auto Turbo: Maintaining turbo state ({}) - no change needed",
|
||||
if enable_turbo { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
|
65
src/fs.rs
Normal file
65
src/fs.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use std::{error, fs, io, path::Path, str};
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
|
||||
path.exists()
|
||||
}
|
||||
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> anyhow::Result<Option<fs::ReadDir>> {
|
||||
let path = path.as_ref();
|
||||
|
||||
match fs::read_dir(path) {
|
||||
Ok(entries) => Ok(Some(entries)),
|
||||
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
|
||||
Err(error) => Err(error).context(format!(
|
||||
"failed to read directory '{path}'",
|
||||
path = path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(path: impl AsRef<Path>) -> anyhow::Result<Option<String>> {
|
||||
let path = path.as_ref();
|
||||
|
||||
match fs::read_to_string(path) {
|
||||
Ok(string) => Ok(Some(string.trim().to_owned())),
|
||||
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
|
||||
Err(error) => Err(error).context(format!("failed to read '{path}", path = path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_n<N: str::FromStr>(path: impl AsRef<Path>) -> anyhow::Result<Option<N>>
|
||||
where
|
||||
N::Err: error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
|
||||
match read(path)? {
|
||||
Some(content) => Ok(Some(content.trim().parse().with_context(|| {
|
||||
format!(
|
||||
"failed to parse contents of '{path}' as a unsigned number",
|
||||
path = path.display(),
|
||||
)
|
||||
})?)),
|
||||
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(path: impl AsRef<Path>, value: &str) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
fs::write(path, value).with_context(|| {
|
||||
format!(
|
||||
"failed to write '{value}' to '{path}'",
|
||||
path = path.display(),
|
||||
)
|
||||
})
|
||||
}
|
541
src/main.rs
541
src/main.rs
|
@ -1,476 +1,121 @@
|
|||
mod battery;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod core;
|
||||
mod cpu;
|
||||
mod power_supply;
|
||||
mod system;
|
||||
|
||||
mod fs;
|
||||
|
||||
mod config;
|
||||
// mod core;
|
||||
mod daemon;
|
||||
mod engine;
|
||||
mod monitor;
|
||||
mod util;
|
||||
// mod engine;
|
||||
// mod monitor;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::{GovernorOverrideMode, TurboSetting};
|
||||
use crate::util::error::{AppError, ControlError};
|
||||
use clap::{Parser, value_parser};
|
||||
use env_logger::Builder;
|
||||
use log::{debug, error, info};
|
||||
use std::error::Error;
|
||||
use std::sync::Once;
|
||||
use anyhow::Context;
|
||||
use clap::Parser as _;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::{io, process};
|
||||
use yansi::Paint as _;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
#[derive(clap::Parser, Debug)]
|
||||
#[clap(author, version, about)]
|
||||
struct Cli {
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
|
||||
#[clap(subcommand)]
|
||||
command: Option<Commands>,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
enum Commands {
|
||||
/// Display current system information
|
||||
#[derive(clap::Parser, Debug)]
|
||||
enum Command {
|
||||
/// Display information.
|
||||
Info,
|
||||
/// Run as a daemon in the background
|
||||
Daemon {
|
||||
#[clap(long)]
|
||||
verbose: bool,
|
||||
},
|
||||
/// Set CPU governor
|
||||
SetGovernor {
|
||||
governor: String,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Force a specific governor mode persistently
|
||||
ForceGovernor {
|
||||
/// Mode to force: performance, powersave, or reset
|
||||
#[clap(value_enum)]
|
||||
mode: GovernorOverrideMode,
|
||||
},
|
||||
/// Set turbo boost behavior
|
||||
SetTurbo {
|
||||
#[clap(value_enum)]
|
||||
setting: TurboSetting,
|
||||
},
|
||||
/// Display comprehensive debug information
|
||||
Debug,
|
||||
/// Set Energy Performance Preference (EPP)
|
||||
SetEpp {
|
||||
epp: String,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set Energy Performance Bias (EPB)
|
||||
SetEpb {
|
||||
epb: String, // Typically 0-15
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set minimum CPU frequency
|
||||
SetMinFreq {
|
||||
freq_mhz: u32,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set maximum CPU frequency
|
||||
SetMaxFreq {
|
||||
freq_mhz: u32,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// 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,
|
||||
|
||||
/// Start the daemon.
|
||||
Start {
|
||||
/// The daemon config path.
|
||||
#[arg(long, env = "WATT_CONFIG")]
|
||||
config: PathBuf,
|
||||
},
|
||||
|
||||
/// Modify CPU attributes.
|
||||
CpuSet(config::CpuDelta),
|
||||
|
||||
/// Modify power supply attributes.
|
||||
PowerSet(config::PowerDelta),
|
||||
}
|
||||
|
||||
fn main() -> Result<(), AppError> {
|
||||
// Initialize logger once for the entire application
|
||||
init_logger();
|
||||
|
||||
fn real_main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Load configuration first, as it might be needed by the monitor module
|
||||
// E.g., for ignored power supplies
|
||||
let config = match config::load_config() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
error!("Error loading configuration: {e}. Using default values.");
|
||||
// Proceed with default config if loading fails
|
||||
AppConfig::default()
|
||||
yansi::whenever(yansi::Condition::TTY_AND_COLOR);
|
||||
|
||||
env_logger::Builder::new()
|
||||
.filter_level(cli.verbosity.log_level_filter())
|
||||
.format_timestamp(None)
|
||||
.format_module_path(false)
|
||||
.init();
|
||||
|
||||
match cli.command {
|
||||
Command::Info => todo!(),
|
||||
|
||||
Command::Start { config } => {
|
||||
let config = config::DaemonConfig::load_from(&config)
|
||||
.context("failed to load daemon config file")?;
|
||||
|
||||
daemon::run(config)
|
||||
}
|
||||
|
||||
Command::CpuSet(delta) => delta.apply(),
|
||||
Command::PowerSet(delta) => delta.apply(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let Err(error) = real_main() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let command_result: Result<(), AppError> = match cli.command {
|
||||
// TODO: This will be moved to a different module in the future.
|
||||
Some(Commands::Info) => match monitor::collect_system_report(&config) {
|
||||
Ok(report) => {
|
||||
// Format section headers with proper centering
|
||||
let format_section = |title: &str| {
|
||||
let title_len = title.len();
|
||||
let total_width = title_len + 8; // 8 is for padding (4 on each side)
|
||||
let separator = "═".repeat(total_width);
|
||||
let mut err = io::stderr();
|
||||
|
||||
println!("\n╔{separator}╗");
|
||||
let mut message = String::new();
|
||||
let mut chain = error.chain().rev().peekable();
|
||||
|
||||
// Calculate centering
|
||||
println!("║ {title} ║");
|
||||
|
||||
println!("╚{separator}╝");
|
||||
};
|
||||
|
||||
format_section("System Information");
|
||||
println!("CPU Model: {}", report.system_info.cpu_model);
|
||||
println!("Architecture: {}", report.system_info.architecture);
|
||||
println!(
|
||||
"Linux Distribution: {}",
|
||||
report.system_info.linux_distribution
|
||||
);
|
||||
|
||||
// Format timestamp in a readable way
|
||||
println!("Current Time: {}", jiff::Timestamp::now());
|
||||
|
||||
format_section("CPU Global Info");
|
||||
println!(
|
||||
"Current Governor: {}",
|
||||
report
|
||||
.cpu_global
|
||||
.current_governor
|
||||
.as_deref()
|
||||
.unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"Available Governors: {}", // 21 length baseline
|
||||
report.cpu_global.available_governors.join(", ")
|
||||
);
|
||||
println!(
|
||||
"Turbo Status: {}",
|
||||
match report.cpu_global.turbo_status {
|
||||
Some(true) => "Enabled",
|
||||
Some(false) => "Disabled",
|
||||
None => "Unknown",
|
||||
}
|
||||
);
|
||||
|
||||
println!(
|
||||
"EPP: {}",
|
||||
report.cpu_global.epp.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"EPB: {}",
|
||||
report.cpu_global.epb.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"Platform Profile: {}",
|
||||
report
|
||||
.cpu_global
|
||||
.platform_profile
|
||||
.as_deref()
|
||||
.unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"CPU Temperature: {}",
|
||||
report.cpu_global.average_temperature_celsius.map_or_else(
|
||||
|| "N/A (No sensor detected)".to_string(),
|
||||
|t| format!("{t:.1}°C")
|
||||
)
|
||||
);
|
||||
|
||||
format_section("CPU Core Info");
|
||||
|
||||
// Get max core ID length for padding
|
||||
let max_core_id_len = report
|
||||
.cpu_cores
|
||||
.last()
|
||||
.map_or(1, |core| core.core_id.to_string().len());
|
||||
|
||||
// Table headers
|
||||
println!(
|
||||
" {:>width$} │ {:^10} │ {:^10} │ {:^10} │ {:^7} │ {:^9}",
|
||||
"Core",
|
||||
"Current",
|
||||
"Min",
|
||||
"Max",
|
||||
"Usage",
|
||||
"Temp",
|
||||
width = max_core_id_len + 4
|
||||
);
|
||||
println!(
|
||||
" {:─>width$}──┼─{:─^10}─┼─{:─^10}─┼─{:─^10}─┼─{:─^7}─┼─{:─^9}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
width = max_core_id_len + 4
|
||||
);
|
||||
|
||||
for core_info in &report.cpu_cores {
|
||||
// Format frequencies: if current > max, show in a special way
|
||||
let current_freq = match core_info.current_frequency_mhz {
|
||||
Some(freq) => {
|
||||
let max_freq = core_info.max_frequency_mhz.unwrap_or(0);
|
||||
if freq > max_freq && max_freq > 0 {
|
||||
// Special format for boosted frequencies
|
||||
format!("{freq}*")
|
||||
} else {
|
||||
format!("{freq}")
|
||||
}
|
||||
}
|
||||
None => "N/A".to_string(),
|
||||
};
|
||||
|
||||
// CPU core display
|
||||
println!(
|
||||
" Core {:<width$} │ {:>10} │ {:>10} │ {:>10} │ {:>7} │ {:>9}",
|
||||
core_info.core_id,
|
||||
format!("{} MHz", current_freq),
|
||||
format!(
|
||||
"{} MHz",
|
||||
core_info
|
||||
.min_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
),
|
||||
format!(
|
||||
"{} MHz",
|
||||
core_info
|
||||
.max_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
),
|
||||
format!(
|
||||
"{}%",
|
||||
core_info
|
||||
.usage_percent
|
||||
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
|
||||
),
|
||||
format!(
|
||||
"{}°C",
|
||||
core_info
|
||||
.temperature_celsius
|
||||
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
|
||||
),
|
||||
width = max_core_id_len
|
||||
);
|
||||
}
|
||||
|
||||
// Only display battery info for systems that have real batteries
|
||||
// Skip this section entirely on desktop systems
|
||||
if !report.batteries.is_empty() {
|
||||
let has_real_batteries = report.batteries.iter().any(|b| {
|
||||
// Check if any battery has actual battery data
|
||||
// (as opposed to peripherals like wireless mice)
|
||||
b.capacity_percent.is_some() || b.power_rate_watts.is_some()
|
||||
});
|
||||
|
||||
if has_real_batteries {
|
||||
format_section("Battery Info");
|
||||
for battery_info in &report.batteries {
|
||||
// Check if this appears to be a real system battery
|
||||
if battery_info.capacity_percent.is_some()
|
||||
|| battery_info.power_rate_watts.is_some()
|
||||
{
|
||||
let power_status = if battery_info.ac_connected {
|
||||
"Connected to AC"
|
||||
} else {
|
||||
"Running on Battery"
|
||||
};
|
||||
|
||||
println!("Battery {}:", battery_info.name);
|
||||
println!(" Power Status: {power_status}");
|
||||
println!(
|
||||
" State: {}",
|
||||
battery_info.charging_state.as_deref().unwrap_or("Unknown")
|
||||
);
|
||||
|
||||
if let Some(capacity) = battery_info.capacity_percent {
|
||||
println!(" Capacity: {capacity}%");
|
||||
}
|
||||
|
||||
if let Some(power) = battery_info.power_rate_watts {
|
||||
let direction = if power >= 0.0 {
|
||||
"charging"
|
||||
} else {
|
||||
"discharging"
|
||||
};
|
||||
println!(
|
||||
" Power Rate: {:.2} W ({})",
|
||||
power.abs(),
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
// Display charge thresholds if available
|
||||
if battery_info.charge_start_threshold.is_some()
|
||||
|| battery_info.charge_stop_threshold.is_some()
|
||||
{
|
||||
println!(
|
||||
" Charge Thresholds: {}-{}",
|
||||
battery_info
|
||||
.charge_start_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string()),
|
||||
battery_info
|
||||
.charge_stop_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format_section("System Load");
|
||||
println!(
|
||||
"Load Average (1m): {:.2}",
|
||||
report.system_load.load_avg_1min
|
||||
);
|
||||
println!(
|
||||
"Load Average (5m): {:.2}",
|
||||
report.system_load.load_avg_5min
|
||||
);
|
||||
println!(
|
||||
"Load Average (15m): {:.2}",
|
||||
report.system_load.load_avg_15min
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(AppError::Monitor(e)),
|
||||
},
|
||||
Some(Commands::SetGovernor { governor, core_id }) => {
|
||||
cpu::set_governor(&governor, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::ForceGovernor { mode }) => {
|
||||
cpu::force_governor(mode).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetTurbo { setting }) => cpu::set_turbo(setting).map_err(AppError::Control),
|
||||
Some(Commands::SetEpp { epp, core_id }) => {
|
||||
cpu::set_epp(&epp, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetEpb { epb, core_id }) => {
|
||||
cpu::set_epb(&epb, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetMinFreq { freq_mhz, core_id }) => {
|
||||
// Basic validation for reasonable CPU frequency values
|
||||
validate_freq(freq_mhz, "Minimum")?;
|
||||
cpu::set_min_frequency(freq_mhz, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetMaxFreq { freq_mhz, core_id }) => {
|
||||
// Basic validation for reasonable CPU frequency values
|
||||
validate_freq(freq_mhz, "Maximum")?;
|
||||
cpu::set_max_frequency(freq_mhz, core_id).map_err(AppError::Control)
|
||||
}
|
||||
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(AppError::Control)
|
||||
} else {
|
||||
error!(
|
||||
"Invalid platform profile: '{}'. Available profiles: {}",
|
||||
profile,
|
||||
available_profiles.join(", ")
|
||||
);
|
||||
Err(AppError::Generic(format!(
|
||||
"Invalid platform profile: '{}'. Available profiles: {}",
|
||||
profile,
|
||||
available_profiles.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
// If we can't get profiles (e.g., feature not supported), pass through to the function
|
||||
cpu::set_platform_profile(&profile).map_err(AppError::Control)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(AppError::Generic(format!(
|
||||
"Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})"
|
||||
)))
|
||||
while let Some(error) = chain.next() {
|
||||
let _ = write!(
|
||||
err,
|
||||
"{header} ",
|
||||
header = if chain.peek().is_none() {
|
||||
"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(AppError::Control)
|
||||
"cause:"
|
||||
}
|
||||
}
|
||||
Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose),
|
||||
Some(Commands::Debug) => cli::debug::run_debug(&config),
|
||||
None => {
|
||||
info!("Welcome to superfreq! Use --help for commands.");
|
||||
debug!("Current effective configuration: {config:?}");
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
.red()
|
||||
.bold(),
|
||||
);
|
||||
|
||||
if let Err(e) = command_result {
|
||||
error!("Error executing command: {e}");
|
||||
if let Some(source) = e.source() {
|
||||
error!("Caused by: {source}");
|
||||
}
|
||||
String::clear(&mut message);
|
||||
let _ = write!(message, "{error}");
|
||||
|
||||
// Check for permission denied errors
|
||||
if let AppError::Control(control_error) = &e {
|
||||
if matches!(control_error, ControlError::PermissionDenied(_)) {
|
||||
error!(
|
||||
"Hint: This operation may require administrator privileges (e.g., run with sudo)."
|
||||
);
|
||||
let mut chars = message.char_indices();
|
||||
|
||||
let _ = match (chars.next(), chars.next()) {
|
||||
(Some((_, first)), Some((second_start, second))) if second.is_lowercase() => {
|
||||
writeln!(
|
||||
err,
|
||||
"{first_lowercase}{rest}",
|
||||
first_lowercase = first.to_lowercase(),
|
||||
rest = &message[second_start..],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(1);
|
||||
_ => {
|
||||
writeln!(err, "{message}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the logger for the entire application
|
||||
static LOGGER_INIT: Once = Once::new();
|
||||
fn init_logger() {
|
||||
LOGGER_INIT.call_once(|| {
|
||||
// Set default log level based on environment or default to Info
|
||||
let env_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
|
||||
|
||||
Builder::new()
|
||||
.parse_filters(&env_log)
|
||||
.format_timestamp(None)
|
||||
.format_module_path(false)
|
||||
.init();
|
||||
|
||||
debug!("Logger initialized with RUST_LOG={env_log}");
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate CPU frequency input values
|
||||
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), AppError> {
|
||||
if freq_mhz == 0 {
|
||||
error!("{label} frequency cannot be zero");
|
||||
Err(AppError::Generic(format!(
|
||||
"{label} frequency cannot be zero"
|
||||
)))
|
||||
} else if freq_mhz > 10000 {
|
||||
// Extremely high value unlikely to be valid
|
||||
error!("{label} frequency ({freq_mhz} MHz) is unreasonably high");
|
||||
Err(AppError::Generic(format!(
|
||||
"{label} frequency ({freq_mhz} MHz) is unreasonably high"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
|
|
784
src/monitor.rs
784
src/monitor.rs
|
@ -1,8 +1,5 @@
|
|||
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,
|
||||
|
@ -13,719 +10,32 @@ use std::{
|
|||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub type Result<T, E = SysMonitorError> = std::result::Result<T, E>;
|
||||
|
||||
// Read a sysfs file to a string, trimming whitespace
|
||||
fn read_sysfs_file_trimmed(path: impl AsRef<Path>) -> Result<String> {
|
||||
fs::read_to_string(path.as_ref())
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| {
|
||||
SysMonitorError::ReadError(format!("Path: {:?}, Error: {}", path.as_ref().display(), e))
|
||||
})
|
||||
}
|
||||
|
||||
// Read a sysfs file and parse it to a specific type
|
||||
fn read_sysfs_value<T: FromStr>(path: impl AsRef<Path>) -> Result<T> {
|
||||
let content = read_sysfs_file_trimmed(path.as_ref())?;
|
||||
content.parse::<T>().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!(
|
||||
"Could not parse '{}' from {:?}",
|
||||
content,
|
||||
path.as_ref().display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let linux_distribution = get_linux_distribution().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let architecture = std::env::consts::ARCH.to_string();
|
||||
|
||||
SystemInfo {
|
||||
cpu_model,
|
||||
architecture,
|
||||
linux_distribution,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CpuTimes {
|
||||
user: u64,
|
||||
nice: u64,
|
||||
system: u64,
|
||||
idle: u64,
|
||||
iowait: u64,
|
||||
irq: u64,
|
||||
softirq: u64,
|
||||
steal: u64,
|
||||
}
|
||||
|
||||
impl CpuTimes {
|
||||
const fn total_time(&self) -> u64 {
|
||||
self.user
|
||||
+ self.nice
|
||||
+ self.system
|
||||
+ self.idle
|
||||
+ self.iowait
|
||||
+ self.irq
|
||||
+ self.softirq
|
||||
+ self.steal
|
||||
}
|
||||
|
||||
const fn idle_time(&self) -> u64 {
|
||||
self.idle + self.iowait
|
||||
}
|
||||
}
|
||||
|
||||
fn read_all_cpu_times() -> Result<HashMap<u32, CpuTimes>> {
|
||||
let content = fs::read_to_string("/proc/stat").map_err(SysMonitorError::Io)?;
|
||||
let mut cpu_times_map = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("cpu") && line.chars().nth(3).is_some_and(|c| c.is_ascii_digit()) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 11 {
|
||||
return Err(SysMonitorError::ProcStatParseError(format!(
|
||||
"Line too short: {line}"
|
||||
)));
|
||||
}
|
||||
|
||||
let core_id_str = &parts[0][3..];
|
||||
let core_id = core_id_str.parse::<u32>().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse core_id: {core_id_str}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let times = CpuTimes {
|
||||
user: parts[1].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse user time: {}",
|
||||
parts[1]
|
||||
))
|
||||
})?,
|
||||
nice: parts[2].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse nice time: {}",
|
||||
parts[2]
|
||||
))
|
||||
})?,
|
||||
system: parts[3].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse system time: {}",
|
||||
parts[3]
|
||||
))
|
||||
})?,
|
||||
idle: parts[4].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse idle time: {}",
|
||||
parts[4]
|
||||
))
|
||||
})?,
|
||||
iowait: parts[5].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse iowait time: {}",
|
||||
parts[5]
|
||||
))
|
||||
})?,
|
||||
irq: parts[6].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse irq time: {}",
|
||||
parts[6]
|
||||
))
|
||||
})?,
|
||||
softirq: parts[7].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse softirq time: {}",
|
||||
parts[7]
|
||||
))
|
||||
})?,
|
||||
steal: parts[8].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse steal time: {}",
|
||||
parts[8]
|
||||
))
|
||||
})?,
|
||||
};
|
||||
cpu_times_map.insert(core_id, times);
|
||||
}
|
||||
}
|
||||
Ok(cpu_times_map)
|
||||
}
|
||||
|
||||
pub fn get_cpu_core_info(
|
||||
core_id: u32,
|
||||
prev_times: &CpuTimes,
|
||||
current_times: &CpuTimes,
|
||||
) -> Result<CpuCoreInfo> {
|
||||
let cpufreq_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/"));
|
||||
|
||||
let current_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_cur_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
let min_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_min_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
let max_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_max_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
|
||||
// Temperature detection.
|
||||
// Should be generic enough to be able to support for multiple hardware sensors
|
||||
// with the possibility of extending later down the road.
|
||||
let mut temperature_celsius: Option<f32> = None;
|
||||
|
||||
// Search for temperature in hwmon devices
|
||||
if let Ok(hwmon_dir) = fs::read_dir("/sys/class/hwmon") {
|
||||
for hw_entry in hwmon_dir.flatten() {
|
||||
let hw_path = hw_entry.path();
|
||||
|
||||
// Check hwmon driver name
|
||||
if let Ok(name) = read_sysfs_file_trimmed(hw_path.join("name")) {
|
||||
// Intel CPU temperature driver
|
||||
if name == "coretemp" {
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// AMD CPU temperature driver
|
||||
// TODO: 'zenergy' can also report those stats, I think?
|
||||
else if name == "k10temp" || name == "zenpower" || name == "amdgpu" {
|
||||
// AMD's k10temp doesn't always label cores individually
|
||||
// First try to find core-specific temps
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Tdie") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try Tctl temperature (CPU control temp)
|
||||
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "Tctl") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try CPU temperature
|
||||
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "CPU") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to any available temperature input without a specific label
|
||||
temperature_celsius = get_fallback_temperature(&hw_path);
|
||||
if temperature_celsius.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Other CPU temperature drivers
|
||||
else if name.contains("cpu") || name.contains("temp") {
|
||||
// Try to find a label that matches this core
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to any temperature reading if specific core not found
|
||||
temperature_celsius = get_fallback_temperature(&hw_path);
|
||||
if temperature_celsius.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try /sys/devices/platform paths for thermal zones as a last resort
|
||||
if temperature_celsius.is_none() {
|
||||
if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
|
||||
for entry in thermal_zones.flatten() {
|
||||
let zone_path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
if name.starts_with("thermal_zone") {
|
||||
// Try to match by type
|
||||
if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
|
||||
if zone_type.contains("cpu")
|
||||
|| zone_type.contains("x86")
|
||||
|| zone_type.contains("core")
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
|
||||
temperature_celsius = Some(temp_mc as f32 / 1000.0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usage_percent: Option<f32> = {
|
||||
let prev_idle = prev_times.idle_time();
|
||||
let current_idle = current_times.idle_time();
|
||||
|
||||
let prev_total = prev_times.total_time();
|
||||
let current_total = current_times.total_time();
|
||||
|
||||
let total_diff = current_total.saturating_sub(prev_total);
|
||||
let idle_diff = current_idle.saturating_sub(prev_idle);
|
||||
|
||||
// Avoid division by zero if no time has passed or counters haven't changed
|
||||
if total_diff == 0 {
|
||||
None
|
||||
} else {
|
||||
let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32));
|
||||
Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CpuCoreInfo {
|
||||
core_id,
|
||||
current_frequency_mhz,
|
||||
min_frequency_mhz,
|
||||
max_frequency_mhz,
|
||||
usage_percent,
|
||||
temperature_celsius,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds core-specific temperature
|
||||
fn get_temperature_for_core(hw_path: &Path, core_id: u32, label_prefix: &str) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
// Increased range to handle systems with many sensors
|
||||
let label_path = hw_path.join(format!("temp{i}_label"));
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if label_path.exists() && input_path.exists() {
|
||||
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
|
||||
// Match various common label formats:
|
||||
// "Core X", "core X", "Core-X", "CPU Core X", etc.
|
||||
let core_pattern = format!("{label_prefix} {core_id}");
|
||||
let alt_pattern = format!("{label_prefix}-{core_id}");
|
||||
|
||||
if label.eq_ignore_ascii_case(&core_pattern)
|
||||
|| label.eq_ignore_ascii_case(&alt_pattern)
|
||||
|| label
|
||||
.to_lowercase()
|
||||
.contains(&format!("core {core_id}").to_lowercase())
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Finds generic sensor temperatures by label
|
||||
fn get_generic_sensor_temperature(hw_path: &Path, label_name: &str) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
let label_path = hw_path.join(format!("temp{i}_label"));
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if label_path.exists() && input_path.exists() {
|
||||
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
|
||||
if label.eq_ignore_ascii_case(label_name)
|
||||
|| label.to_lowercase().contains(&label_name.to_lowercase())
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !label_path.exists() && input_path.exists() {
|
||||
// Some sensors might not have labels but still have valid temp inputs
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Fallback to any temperature reading from a sensor
|
||||
fn get_fallback_temperature(hw_path: &Path) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if input_path.exists() {
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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
|
||||
let final_cpu_times = read_all_cpu_times()?;
|
||||
|
||||
let num_cores = get_logical_core_count()
|
||||
.map_err(|_| SysMonitorError::ReadError("Could not get the number of cores".to_string()))?;
|
||||
|
||||
let mut core_infos = Vec::with_capacity(num_cores as usize);
|
||||
|
||||
for core_id in 0..num_cores {
|
||||
if let (Some(prev), Some(curr)) = (
|
||||
initial_cpu_times.get(&core_id),
|
||||
final_cpu_times.get(&core_id),
|
||||
) {
|
||||
match get_cpu_core_info(core_id, prev, curr) {
|
||||
Ok(info) => core_infos.push(info),
|
||||
Err(e) => {
|
||||
// Log or handle error for a single core, maybe push a partial info or skip
|
||||
eprintln!("Error getting info for core {core_id}: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log or handle missing times for a core
|
||||
eprintln!("Missing CPU time data for core {core_id}");
|
||||
}
|
||||
}
|
||||
Ok(core_infos)
|
||||
}
|
||||
|
||||
pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo {
|
||||
// Find a valid CPU to read global settings from
|
||||
// Try cpu0 first, then fall back to any available CPU with cpufreq
|
||||
let mut cpufreq_base_path_buf = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/");
|
||||
|
||||
if !cpufreq_base_path_buf.exists() {
|
||||
let core_count = get_logical_core_count().unwrap_or_else(|e| {
|
||||
eprintln!("Warning: {e}");
|
||||
0
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let turbo_status_path = Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo");
|
||||
let boost_path = Path::new("/sys/devices/system/cpu/cpufreq/boost");
|
||||
|
||||
let current_governor = if cpufreq_base_path_buf.join("scaling_governor").exists() {
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_governor")).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let available_governors = if cpufreq_base_path_buf
|
||||
.join("scaling_available_governors")
|
||||
.exists()
|
||||
{
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_available_governors"))
|
||||
.map_or_else(
|
||||
|_| vec![],
|
||||
|s| s.split_whitespace().map(String::from).collect(),
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let turbo_status = if turbo_status_path.exists() {
|
||||
// 0 means turbo enabled, 1 means disabled for intel_pstate
|
||||
read_sysfs_value::<u8>(turbo_status_path)
|
||||
.map(|val| val == 0)
|
||||
.ok()
|
||||
} else if boost_path.exists() {
|
||||
// 1 means turbo enabled, 0 means disabled for generic cpufreq boost
|
||||
read_sysfs_value::<u8>(boost_path).map(|val| val == 1).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// EPP (Energy Performance Preference)
|
||||
let energy_perf_pref =
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_preference")).ok();
|
||||
|
||||
// EPB (Energy Performance Bias)
|
||||
let energy_perf_bias =
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_bias")).ok();
|
||||
|
||||
let platform_profile = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile").ok();
|
||||
|
||||
// Calculate average CPU temperature from the core temperatures
|
||||
let average_temperature_celsius = if cpu_cores.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Filter cores with temperature readings, then calculate average
|
||||
let cores_with_temp: Vec<&CpuCoreInfo> = cpu_cores
|
||||
.iter()
|
||||
.filter(|core| core.temperature_celsius.is_some())
|
||||
.collect();
|
||||
|
||||
if cores_with_temp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Sum up all temperatures and divide by count
|
||||
let sum: f32 = cores_with_temp
|
||||
.iter()
|
||||
.map(|core| core.temperature_celsius.unwrap())
|
||||
.sum();
|
||||
Some(sum / cores_with_temp.len() as f32)
|
||||
}
|
||||
};
|
||||
|
||||
// Return the constructed CpuGlobalInfo
|
||||
CpuGlobalInfo {
|
||||
current_governor,
|
||||
available_governors,
|
||||
turbo_status,
|
||||
epp: energy_perf_pref,
|
||||
epb: energy_perf_bias,
|
||||
platform_profile,
|
||||
average_temperature_celsius,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
|
||||
let mut batteries = Vec::new();
|
||||
let power_supply_path = Path::new("/sys/class/power_supply");
|
||||
|
||||
if !power_supply_path.exists() {
|
||||
return Ok(batteries); // no power supply directory
|
||||
}
|
||||
|
||||
let ignored_supplies = config.ignored_power_supplies.clone().unwrap_or_default();
|
||||
|
||||
// Determine overall AC connection status
|
||||
let mut overall_ac_connected = false;
|
||||
for entry in fs::read_dir(power_supply_path)? {
|
||||
let entry = entry?;
|
||||
let ps_path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
// Check for AC adapter type (common names: AC, ACAD, ADP)
|
||||
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
|
||||
if ps_type == "Mains"
|
||||
|| ps_type == "USB_PD_DRP"
|
||||
|| ps_type == "USB_PD"
|
||||
|| ps_type == "USB_DCP"
|
||||
|| ps_type == "USB_CDP"
|
||||
|| ps_type == "USB_ACA"
|
||||
{
|
||||
// USB types can also provide power
|
||||
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
|
||||
if online == 1 {
|
||||
overall_ac_connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") {
|
||||
// Fallback for type file missing
|
||||
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
|
||||
if online == 1 {
|
||||
overall_ac_connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
if ignored_supplies.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
let power_rate_watts = if ps_path.join("power_now").exists() {
|
||||
read_sysfs_value::<i32>(ps_path.join("power_now")) // uW
|
||||
.map(|uw| uw as f32 / 1_000_000.0)
|
||||
.ok()
|
||||
} else if ps_path.join("current_now").exists()
|
||||
&& ps_path.join("voltage_now").exists()
|
||||
{
|
||||
let current_ua = read_sysfs_value::<i32>(ps_path.join("current_now")).ok(); // uA
|
||||
let voltage_uv = read_sysfs_value::<i32>(ps_path.join("voltage_now")).ok(); // uV
|
||||
if let (Some(c), Some(v)) = (current_ua, voltage_uv) {
|
||||
// Power (W) = (Voltage (V) * Current (A))
|
||||
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
|
||||
Some((f64::from(c) * f64::from(v) / 1_000_000_000_000.0) as f32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let charge_start_threshold =
|
||||
read_sysfs_value::<u8>(ps_path.join("charge_control_start_threshold")).ok();
|
||||
let charge_stop_threshold =
|
||||
read_sysfs_value::<u8>(ps_path.join("charge_control_end_threshold")).ok();
|
||||
|
||||
batteries.push(BatteryInfo {
|
||||
name: name.clone(),
|
||||
ac_connected: overall_ac_connected,
|
||||
charging_state: status_str,
|
||||
capacity_percent,
|
||||
power_rate_watts,
|
||||
charge_start_threshold,
|
||||
charge_stop_threshold,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
if parts.len() < 3 {
|
||||
return Err(SysMonitorError::ParseError(
|
||||
"Could not parse /proc/loadavg: expected at least 3 parts".to_string(),
|
||||
));
|
||||
}
|
||||
let load_avg_1min = parts[0].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 1min load: {}", parts[0]))
|
||||
})?;
|
||||
let load_avg_5min = parts[1].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 5min load: {}", parts[1]))
|
||||
})?;
|
||||
let load_avg_15min = parts[2].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 15min load: {}", parts[2]))
|
||||
})?;
|
||||
|
||||
Ok(SystemLoad {
|
||||
load_avg_1min,
|
||||
load_avg_5min,
|
||||
load_avg_15min,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn collect_system_report(config: &AppConfig) -> Result<SystemReport> {
|
||||
let system_info = get_system_info();
|
||||
let cpu_cores = get_all_cpu_core_info()?;
|
||||
let cpu_global = get_cpu_global_info(&cpu_cores);
|
||||
let batteries = get_battery_info(config)?;
|
||||
let system_load = get_system_load()?;
|
||||
|
||||
Ok(SystemReport {
|
||||
system_info,
|
||||
cpu_cores,
|
||||
cpu_global,
|
||||
batteries,
|
||||
system_load,
|
||||
timestamp: SystemTime::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_cpu_model() -> Result<String> {
|
||||
// Try /sys/devices/platform paths for thermal zones as a last resort
|
||||
// if temperature_celsius.is_none() {
|
||||
// if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
|
||||
// for entry in thermal_zones.flatten() {
|
||||
// let zone_path = entry.path();
|
||||
// let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
// if name.starts_with("thermal_zone") {
|
||||
// // Try to match by type
|
||||
// if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
|
||||
// if zone_type.contains("cpu")
|
||||
// || zone_type.contains("x86")
|
||||
// || zone_type.contains("core")
|
||||
// {
|
||||
// if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
|
||||
// temperature_celsius = Some(temp_mc as f32 / 1000.0);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn get_cpu_model() -> anyhow::Result<String> {
|
||||
let path = Path::new("/proc/cpuinfo");
|
||||
let content = fs::read_to_string(path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display()))
|
||||
|
@ -743,45 +53,3 @@ pub fn get_cpu_model() -> Result<String> {
|
|||
"Could not find CPU model name in /proc/cpuinfo.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_linux_distribution() -> Result<String> {
|
||||
let os_release_path = Path::new("/etc/os-release");
|
||||
let content = fs::read_to_string(os_release_path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!(
|
||||
"Cannot read contents of {}.",
|
||||
os_release_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("PRETTY_NAME=") {
|
||||
if let Some(val) = line.split('=').nth(1) {
|
||||
let linux_distribution = val.trim_matches('"').to_string();
|
||||
return Ok(linux_distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lsb_release_path = Path::new("/etc/lsb-release");
|
||||
let content = fs::read_to_string(lsb_release_path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!(
|
||||
"Cannot read contents of {}.",
|
||||
lsb_release_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("DISTRIB_DESCRIPTION=") {
|
||||
if let Some(val) = line.split('=').nth(1) {
|
||||
let linux_distribution = val.trim_matches('"').to_string();
|
||||
return Ok(linux_distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(SysMonitorError::ParseError(format!(
|
||||
"Could not find distribution name in {} or {}.",
|
||||
os_release_path.display(),
|
||||
lsb_release_path.display()
|
||||
)))
|
||||
}
|
||||
|
|
370
src/power_supply.rs
Normal file
370
src/power_supply.rs
Normal file
|
@ -0,0 +1,370 @@
|
|||
use anyhow::{Context, anyhow, bail};
|
||||
use yansi::Paint as _;
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::fs;
|
||||
|
||||
/// Represents a pattern of path suffixes used to control charge thresholds
|
||||
/// for different device vendors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PowerSupplyThresholdConfig {
|
||||
pub manufacturer: &'static str,
|
||||
pub path_start: &'static str,
|
||||
pub path_end: &'static str,
|
||||
}
|
||||
|
||||
/// Power supply threshold configs.
|
||||
const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "Standard",
|
||||
path_start: "charge_control_start_threshold",
|
||||
path_end: "charge_control_end_threshold",
|
||||
},
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "ASUS",
|
||||
path_start: "charge_control_start_percentage",
|
||||
path_end: "charge_control_end_percentage",
|
||||
},
|
||||
// Combine Huawei and ThinkPad since they use identical paths.
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "ThinkPad/Huawei",
|
||||
path_start: "charge_start_threshold",
|
||||
path_end: "charge_stop_threshold",
|
||||
},
|
||||
// Framework laptop support.
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "Framework",
|
||||
path_start: "charge_behaviour_start_threshold",
|
||||
path_end: "charge_behaviour_end_threshold",
|
||||
},
|
||||
];
|
||||
|
||||
/// Represents a power supply that supports charge threshold control.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PowerSupply {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
|
||||
pub type_: String,
|
||||
pub is_from_peripheral: bool,
|
||||
|
||||
pub charge_state: Option<String>,
|
||||
pub charge_percent: Option<f64>,
|
||||
|
||||
pub charge_threshold_start: f64,
|
||||
pub charge_threshold_end: f64,
|
||||
|
||||
pub drain_rate_watts: Option<f64>,
|
||||
|
||||
pub threshold_config: Option<PowerSupplyThresholdConfig>,
|
||||
}
|
||||
|
||||
impl PowerSupply {
|
||||
pub fn is_ac(&self) -> bool {
|
||||
!self.is_from_peripheral
|
||||
&& matches!(
|
||||
&*self.type_,
|
||||
"Mains" | "USB_PD_DRP" | "USB_PD" | "USB_DCP" | "USB_CDP" | "USB_ACA"
|
||||
)
|
||||
|| self.type_.starts_with("AC")
|
||||
|| self.type_.contains("ACAD")
|
||||
|| self.type_.contains("ADP")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PowerSupply {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "power supply '{name}'", name = self.name.yellow())?;
|
||||
|
||||
if let Some(config) = self.threshold_config.as_ref() {
|
||||
write!(
|
||||
f,
|
||||
" from manufacturer '{manufacturer}'",
|
||||
manufacturer = config.manufacturer.green(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const POWER_SUPPLY_PATH: &str = "/sys/class/power_supply";
|
||||
|
||||
impl PowerSupply {
|
||||
pub fn from_name(name: String) -> anyhow::Result<Self> {
|
||||
let mut power_supply = Self {
|
||||
path: Path::new(POWER_SUPPLY_PATH).join(&name),
|
||||
name,
|
||||
type_: String::new(),
|
||||
|
||||
charge_state: None,
|
||||
charge_percent: None,
|
||||
|
||||
charge_threshold_start: 0.0,
|
||||
charge_threshold_end: 1.0,
|
||||
|
||||
drain_rate_watts: None,
|
||||
|
||||
is_from_peripheral: false,
|
||||
|
||||
threshold_config: None,
|
||||
};
|
||||
|
||||
power_supply.rescan()?;
|
||||
|
||||
Ok(power_supply)
|
||||
}
|
||||
|
||||
pub fn from_path(path: PathBuf) -> anyhow::Result<Self> {
|
||||
let mut power_supply = PowerSupply {
|
||||
name: path
|
||||
.file_name()
|
||||
.with_context(|| {
|
||||
format!("failed to get file name of '{path}'", path = path.display(),)
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
|
||||
path,
|
||||
type_: String::new(),
|
||||
|
||||
charge_state: None,
|
||||
charge_percent: None,
|
||||
|
||||
charge_threshold_start: 0.0,
|
||||
charge_threshold_end: 1.0,
|
||||
|
||||
drain_rate_watts: None,
|
||||
|
||||
is_from_peripheral: false,
|
||||
|
||||
threshold_config: None,
|
||||
};
|
||||
|
||||
power_supply.rescan()?;
|
||||
|
||||
Ok(power_supply)
|
||||
}
|
||||
|
||||
pub fn all() -> anyhow::Result<Vec<PowerSupply>> {
|
||||
let mut power_supplies = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(POWER_SUPPLY_PATH)
|
||||
.with_context(|| format!("failed to read '{POWER_SUPPLY_PATH}'"))?
|
||||
.with_context(|| format!("'{POWER_SUPPLY_PATH}' doesn't exist, are you on linux?"))?
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
|
||||
Err(error) => {
|
||||
log::warn!("failed to read power supply entry: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
power_supplies.push(PowerSupply::from_path(entry.path())?);
|
||||
}
|
||||
|
||||
Ok(power_supplies)
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self) -> anyhow::Result<()> {
|
||||
if !self.path.exists() {
|
||||
bail!("{self} does not exist");
|
||||
}
|
||||
|
||||
self.type_ = {
|
||||
let type_path = self.path.join("type");
|
||||
|
||||
fs::read(&type_path)
|
||||
.with_context(|| format!("failed to read '{path}'", path = type_path.display()))?
|
||||
.with_context(|| format!("'{path}' doesn't exist", path = type_path.display()))?
|
||||
};
|
||||
|
||||
self.is_from_peripheral = 'is_from_peripheral: {
|
||||
let name_lower = self.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")
|
||||
{
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
|
||||
// Small capacity batteries are likely not laptop batteries.
|
||||
if let Some(energy_full) = fs::read_n::<u64>(self.path.join("energy_full"))
|
||||
.with_context(|| format!("failed to read the max charge {self} can hold"))?
|
||||
{
|
||||
// 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.
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
}
|
||||
// Check for model name that indicates a peripheral
|
||||
if let Some(model) = fs::read(self.path.join("model_name"))
|
||||
.with_context(|| format!("failed to read the model name of {self}"))?
|
||||
{
|
||||
if model.contains("bluetooth") || model.contains("wireless") {
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
};
|
||||
|
||||
if self.type_ == "Battery" {
|
||||
self.charge_state = fs::read(self.path.join("status"))
|
||||
.with_context(|| format!("failed to read {self} charge status"))?;
|
||||
|
||||
self.charge_percent = fs::read_n::<u64>(self.path.join("capacity"))
|
||||
.with_context(|| format!("failed to read {self} charge percent"))?
|
||||
.map(|percent| percent as f64 / 100.0);
|
||||
|
||||
self.charge_threshold_start =
|
||||
fs::read_n::<u64>(self.path.join("charge_control_start_threshold"))
|
||||
.with_context(|| format!("failed to read {self} charge threshold start"))?
|
||||
.map_or(0.0, |percent| percent as f64 / 100.0);
|
||||
|
||||
self.charge_threshold_end =
|
||||
fs::read_n::<u64>(self.path.join("charge_control_end_threshold"))
|
||||
.with_context(|| format!("failed to read {self} charge threshold end"))?
|
||||
.map_or(100.0, |percent| percent as f64 / 100.0);
|
||||
|
||||
self.drain_rate_watts = match fs::read_n::<i64>(self.path.join("power_now"))
|
||||
.with_context(|| format!("failed to read {self} power drain"))?
|
||||
{
|
||||
Some(drain) => Some(drain as f64),
|
||||
|
||||
None => {
|
||||
let current_ua = fs::read_n::<i32>(self.path.join("current_now"))
|
||||
.with_context(|| format!("failed to read {self} current"))?;
|
||||
|
||||
let voltage_uv = fs::read_n::<i32>(self.path.join("voltage_now"))
|
||||
.with_context(|| format!("failed to read {self} voltage"))?;
|
||||
|
||||
current_ua.zip(voltage_uv).map(|(current, voltage)| {
|
||||
// Power (W) = Voltage (V) * Current (A)
|
||||
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
|
||||
current as f64 * voltage as f64 / 1e12
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
self.threshold_config = POWER_SUPPLY_THRESHOLD_CONFIGS
|
||||
.iter()
|
||||
.find(|config| {
|
||||
self.path.join(config.path_start).exists()
|
||||
&& self.path.join(config.path_end).exists()
|
||||
})
|
||||
.copied();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn charge_threshold_path_start(&self) -> Option<PathBuf> {
|
||||
self.threshold_config
|
||||
.map(|config| self.path.join(config.path_start))
|
||||
}
|
||||
|
||||
pub fn charge_threshold_path_end(&self) -> Option<PathBuf> {
|
||||
self.threshold_config
|
||||
.map(|config| self.path.join(config.path_end))
|
||||
}
|
||||
|
||||
pub fn set_charge_threshold_start(
|
||||
&mut self,
|
||||
charge_threshold_start: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
fs::write(
|
||||
&self.charge_threshold_path_start().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"power supply '{name}' does not support changing charge threshold levels",
|
||||
name = self.name,
|
||||
)
|
||||
})?,
|
||||
&((charge_threshold_start * 100.0) as u8).to_string(),
|
||||
)
|
||||
.with_context(|| format!("failed to set charge threshold start for {self}"))?;
|
||||
|
||||
self.charge_threshold_start = charge_threshold_start;
|
||||
|
||||
log::info!("set battery threshold start for {self} to {charge_threshold_start}%");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_charge_threshold_end(&mut self, charge_threshold_end: f64) -> anyhow::Result<()> {
|
||||
fs::write(
|
||||
&self.charge_threshold_path_end().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"power supply '{name}' does not support changing charge threshold levels",
|
||||
name = self.name,
|
||||
)
|
||||
})?,
|
||||
&((charge_threshold_end * 100.0) as u8).to_string(),
|
||||
)
|
||||
.with_context(|| format!("failed to set charge threshold end for {self}"))?;
|
||||
|
||||
self.charge_threshold_end = charge_threshold_end;
|
||||
|
||||
log::info!("set battery threshold end for {self} to {charge_threshold_end}%");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_available_platform_profiles() -> anyhow::Result<Vec<String>> {
|
||||
let path = "/sys/firmware/acpi/platform_profile_choices";
|
||||
|
||||
let Some(content) =
|
||||
fs::read(path).context("failed to read available ACPI platform profiles")?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
Ok(content
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sets the platform profile.
|
||||
/// This changes the system performance, temperature, fan, and other hardware replated characteristics.
|
||||
///
|
||||
/// Also see [`The Kernel docs`] for this.
|
||||
///
|
||||
/// [`The Kernel docs`]: <https://docs.kernel.org/userspace-api/sysfs-platform_profile.html>
|
||||
pub fn set_platform_profile(profile: &str) -> anyhow::Result<()> {
|
||||
let profiles = Self::get_available_platform_profiles()?;
|
||||
|
||||
if !profiles
|
||||
.iter()
|
||||
.any(|avail_profile| avail_profile == profile)
|
||||
{
|
||||
bail!(
|
||||
"profile '{profile}' is not available for system. valid profiles: {profiles}",
|
||||
profiles = profiles.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
fs::write("/sys/firmware/acpi/platform_profile", profile)
|
||||
.context("this probably means that your system does not support changing ACPI profiles")
|
||||
}
|
||||
|
||||
pub fn platform_profile() -> anyhow::Result<String> {
|
||||
fs::read("/sys/firmware/acpi/platform_profile")
|
||||
.context("failed to read platform profile")?
|
||||
.context("failed to find platform profile")
|
||||
}
|
||||
}
|
237
src/system.rs
Normal file
237
src/system.rs
Normal file
|
@ -0,0 +1,237 @@
|
|||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
|
||||
use crate::{cpu, fs, power_supply};
|
||||
|
||||
pub struct System {
|
||||
pub is_ac: bool,
|
||||
|
||||
pub load_average_1min: f64,
|
||||
pub load_average_5min: f64,
|
||||
pub load_average_15min: f64,
|
||||
|
||||
pub cpus: Vec<cpu::Cpu>,
|
||||
pub cpu_temperatures: HashMap<u32, f64>,
|
||||
|
||||
pub power_supplies: Vec<power_supply::PowerSupply>,
|
||||
}
|
||||
|
||||
impl System {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let mut system = Self {
|
||||
is_ac: false,
|
||||
|
||||
cpus: Vec::new(),
|
||||
cpu_temperatures: HashMap::new(),
|
||||
|
||||
power_supplies: Vec::new(),
|
||||
|
||||
load_average_1min: 0.0,
|
||||
load_average_5min: 0.0,
|
||||
load_average_15min: 0.0,
|
||||
};
|
||||
|
||||
system.rescan()?;
|
||||
|
||||
Ok(system)
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self) -> anyhow::Result<()> {
|
||||
self.cpus = cpu::Cpu::all().context("failed to scan CPUs")?;
|
||||
|
||||
self.power_supplies =
|
||||
power_supply::PowerSupply::all().context("failed to scan power supplies")?;
|
||||
|
||||
self.is_ac = self
|
||||
.power_supplies
|
||||
.iter()
|
||||
.any(|power_supply| power_supply.is_ac())
|
||||
|| self.is_desktop()?;
|
||||
|
||||
self.rescan_load_average()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rescan_temperatures(&mut self) -> anyhow::Result<()> {
|
||||
const PATH: &str = "/sys/class/hwmon";
|
||||
|
||||
let mut temperatures = HashMap::new();
|
||||
|
||||
for entry in fs::read_dir(PATH)
|
||||
.with_context(|| format!("failed to read hardware information from '{PATH}'"))?
|
||||
.with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))?
|
||||
{
|
||||
let entry = entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
|
||||
|
||||
let entry_path = entry.path();
|
||||
|
||||
let Some(name) = fs::read(entry_path.join("name")).with_context(|| {
|
||||
format!(
|
||||
"failed to read name of hardware entry at '{path}'",
|
||||
path = entry_path.display(),
|
||||
)
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match &*name {
|
||||
// TODO: 'zenergy' can also report those stats, I think?
|
||||
"coretemp" | "k10temp" | "zenpower" | "amdgpu" => {
|
||||
Self::get_temperatures(&entry_path, &mut temperatures)?;
|
||||
}
|
||||
|
||||
// Other CPU temperature drivers.
|
||||
_ if name.contains("cpu") || name.contains("temp") => {
|
||||
Self::get_temperatures(&entry_path, &mut temperatures)?;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.cpu_temperatures = temperatures;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_temperatures(
|
||||
device_path: &Path,
|
||||
temperatures: &mut HashMap<u32, f64>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Increased range to handle systems with many sensors.
|
||||
for i in 1..=96 {
|
||||
let label_path = device_path.join(format!("temp{i}_label"));
|
||||
let input_path = device_path.join(format!("temp{i}_input"));
|
||||
|
||||
if !label_path.exists() || !input_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(label) = fs::read(&label_path).with_context(|| {
|
||||
format!(
|
||||
"failed to read hardware hardware device label from '{path}'",
|
||||
path = label_path.display(),
|
||||
)
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Match various common label formats:
|
||||
// "Core X", "core X", "Core-X", "CPU Core X", etc.
|
||||
let number = label
|
||||
.trim_start_matches("cpu")
|
||||
.trim_start_matches("CPU")
|
||||
.trim_start()
|
||||
.trim_start_matches("core")
|
||||
.trim_start_matches("Core")
|
||||
.trim_start()
|
||||
.trim_start_matches("tdie")
|
||||
.trim_start_matches("Tdie")
|
||||
.trim_start()
|
||||
.trim_start_matches("tctl")
|
||||
.trim_start_matches("Tctl")
|
||||
.trim_start()
|
||||
.trim_start_matches("-")
|
||||
.trim();
|
||||
|
||||
let Ok(number) = number.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(temperature_mc) = fs::read_n::<i64>(&input_path).with_context(|| {
|
||||
format!(
|
||||
"failed to read CPU temperature from '{path}'",
|
||||
path = input_path.display(),
|
||||
)
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
temperatures.insert(number, temperature_mc as f64 / 1000.0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_desktop(&mut self) -> anyhow::Result<bool> {
|
||||
if let Some(chassis_type) =
|
||||
fs::read("/sys/class/dmi/id/chassis_type").context("failed to read chassis type")?
|
||||
{
|
||||
// 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,
|
||||
// 31=Convertible Laptop
|
||||
match chassis_type.trim() {
|
||||
// Desktop form factors.
|
||||
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Laptop form factors.
|
||||
"9" | "10" | "14" | "31" => {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Unknown, continue with other checks
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 fs::exists(path) {
|
||||
return Ok(false); // Likely a laptop.
|
||||
}
|
||||
}
|
||||
|
||||
// Check CPU power policies, desktops often don't have these
|
||||
let power_saving_exists = fs::exists("/sys/module/intel_pstate/parameters/no_hwp")
|
||||
|| fs::exists("/sys/devices/system/cpu/cpufreq/conservative");
|
||||
|
||||
if !power_saving_exists {
|
||||
return Ok(true); // Likely a desktop.
|
||||
}
|
||||
|
||||
// Default to assuming desktop if we can't determine.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn rescan_load_average(&mut self) -> anyhow::Result<()> {
|
||||
let content = fs::read("/proc/loadavg")
|
||||
.context("failed to read load average from '/proc/loadavg'")?
|
||||
.context("'/proc/loadavg' doesn't exist, are you on linux?")?;
|
||||
|
||||
let mut parts = content.split_whitespace();
|
||||
|
||||
let (Some(load_average_1min), Some(load_average_5min), Some(load_average_15min)) =
|
||||
(parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
bail!(
|
||||
"failed to parse first 3 load average entries due to there not being enough, content: {content}"
|
||||
);
|
||||
};
|
||||
|
||||
self.load_average_1min = load_average_1min
|
||||
.parse()
|
||||
.context("failed to parse load average")?;
|
||||
self.load_average_5min = load_average_5min
|
||||
.parse()
|
||||
.context("failed to parse load average")?;
|
||||
self.load_average_15min = load_average_15min
|
||||
.parse()
|
||||
.context("failed to parse load average")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
use std::io;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ControlError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to write to sysfs path: {0}")]
|
||||
WriteError(String),
|
||||
|
||||
#[error("Failed to read sysfs path: {0}")]
|
||||
ReadError(String),
|
||||
|
||||
#[error("Invalid value for setting: {0}")]
|
||||
InvalidValueError(String),
|
||||
|
||||
#[error("Control action not supported: {0}")]
|
||||
NotSupported(String),
|
||||
|
||||
#[error("Permission denied: {0}. Try running with sudo.")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("Invalid platform control profile {0} supplied, please provide a valid one.")]
|
||||
InvalidProfile(String),
|
||||
|
||||
#[error("Invalid governor: {0}")]
|
||||
InvalidGovernor(String),
|
||||
|
||||
#[error("Failed to parse value: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Path missing: {0}")]
|
||||
PathMissing(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SysMonitorError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to read sysfs path: {0}")]
|
||||
ReadError(String),
|
||||
|
||||
#[error("Failed to parse value: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Failed to parse /proc/stat: {0}")]
|
||||
ProcStatParseError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("CPU control error: {0}")]
|
||||
ControlError(#[from] ControlError),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigurationError(String),
|
||||
}
|
||||
|
||||
// A unified error type for the entire application
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("{0}")]
|
||||
Control(#[from] ControlError),
|
||||
|
||||
#[error("{0}")]
|
||||
Monitor(#[from] SysMonitorError),
|
||||
|
||||
#[error("{0}")]
|
||||
Engine(#[from] EngineError),
|
||||
|
||||
#[error("{0}")]
|
||||
Config(#[from] crate::config::ConfigError),
|
||||
|
||||
#[error("{0}")]
|
||||
Generic(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
pub mod error;
|
||||
pub mod sysfs;
|
|
@ -1,80 +0,0 @@
|
|||
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