1
Fork 0
mirror of https://github.com/RGBCube/watt synced 2025-08-02 19:07:47 +00:00

Compare commits

...

37 commits

Author SHA1 Message Date
2dbc747868
cpu: wip temperature scanning, waiting for raf to stand up from his desk and open his laptop on the other side of the room 2025-05-28 23:18:57 +03:00
6b4972864f
cpu: cache /proc/stat 2025-05-28 21:48:04 +03:00
15bcdd269c
system: check for chassis type 31 and move power saving check below
Co-Authored-By: flashrun24 <flashrun42@gmail.com>
2025-05-27 21:24:36 +03:00
533825beb8
system: is_ac 2025-05-22 20:12:35 +03:00
29944b7dbb
power_supply: add more stuff and store them 2025-05-22 19:49:58 +03:00
1fe3ca3bfd
cpu: add usage percent 2025-05-22 18:40:36 +03:00
3aec909d5d
cpu: add global turbo querying 2025-05-22 17:47:32 +03:00
6caa4f7941
cpu: set_ep{p,b} actually sets the attributes now 2025-05-22 17:42:35 +03:00
24fa53914d
cpu: store EPP and EPB 2025-05-21 18:43:44 +03:00
3212bc0ad5
cpu: store governor and available governors 2025-05-21 18:27:34 +03:00
c1a509328b
cpu: store frequency 2025-05-21 17:51:18 +03:00
dfa788009c
monitor: delete old code 2025-05-21 01:08:19 +03:00
fb5a891d42
main: use yansi::whenever 2025-05-21 00:38:25 +03:00
542c41ccbe
cpu: add TODO 2025-05-21 00:28:08 +03:00
45a9fd4749
cpu: cpu times 2025-05-21 00:24:44 +03:00
ec34526012
cpu&power: add more attributes 2025-05-20 19:53:59 +03:00
d5dbb36de4
fs: fix read() typesig 2025-05-20 19:13:09 +03:00
67e2115588
cpu&power: share fs impls 2025-05-20 19:10:20 +03:00
3a4b5fb530
daemon: delete some old code and create daemon scaffold 2025-05-20 18:55:12 +03:00
a862b0b456
daemon: implement polling_interval 2025-05-20 18:41:08 +03:00
a5151f475b
daemon: wip new impl 2025-05-19 23:58:35 +03:00
923f759533
config: better more enhanched expression 2025-05-19 22:32:42 +03:00
986e7e08b5
power_supply&cpu: kolor 2025-05-19 21:48:01 +03:00
36e4bc05af
power_supply&cpu: somewhat improve error messages 2025-05-19 21:44:45 +03:00
5016825802
main: move application to deltas, comment out broken modules for now 2025-05-19 21:37:58 +03:00
6349055c64
config: fix schema, toml does not have top level lists 2025-05-19 21:32:12 +03:00
98bbf28f3d
config: nuke old config and implement a new system 2025-05-19 21:25:59 +03:00
cc0cc23b0d
power_supply: rename is_battery to get_type and don't compare the type 2025-05-19 18:09:23 +03:00
c69aba87b6
power_supply: add derives to PowerSupply 2025-05-19 18:08:10 +03:00
5559d08f3e
main: delete historical logging code 2025-05-19 17:59:53 +03:00
d61564d5f5
wip unsound broken malfunctioning changes to make it compile 2025-05-19 17:57:07 +03:00
d1247c1570
cpu: impl Display for Cpu 2025-05-19 17:43:12 +03:00
bc343eefd9
power_supply&cpu: use objects 2025-05-19 17:40:30 +03:00
8f3abd1ca3
power_supply: don't ignore non-batteries 2025-05-19 16:17:44 +03:00
a1502009d5
cli: remove governor_persist 2025-05-18 23:21:57 +03:00
8764d3a2ac
battery: clean up, rename to power_supply 2025-05-18 23:21:57 +03:00
76370a7445
cpu: clean up, clean main too 2025-05-18 23:21:57 +03:00
23 changed files with 2330 additions and 3470 deletions

84
Cargo.lock generated
View file

@ -95,6 +95,16 @@ dependencies = [
"clap_derive", "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]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.38" version = "4.5.38"
@ -131,6 +141,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 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]] [[package]]
name = "ctrlc" name = "ctrlc"
version = "3.4.7" version = "3.4.7"
@ -141,6 +160,28 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@ -220,6 +261,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.9.0" version = "2.9.0"
@ -230,6 +277,17 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@ -323,7 +381,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi 0.3.9",
"libc", "libc",
] ]
@ -453,7 +511,9 @@ version = "0.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"clap-verbosity-flag",
"ctrlc", "ctrlc",
"derive_more",
"dirs", "dirs",
"env_logger", "env_logger",
"jiff", "jiff",
@ -462,6 +522,7 @@ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
"toml", "toml",
"yansi",
] ]
[[package]] [[package]]
@ -542,6 +603,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 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]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@ -635,3 +708,12 @@ checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
dependencies = [
"is-terminal",
]

View file

@ -10,7 +10,7 @@ rust-version = "1.85"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "0.8" toml = "0.8"
dirs = "6.0" dirs = "6.0"
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive", "env"] }
num_cpus = "1.16" num_cpus = "1.16"
ctrlc = "3.4" ctrlc = "3.4"
log = "0.4" log = "0.4"
@ -18,3 +18,6 @@ env_logger = "0.11"
thiserror = "2.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
jiff = "0.2.13" 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
View file

@ -0,0 +1,2 @@
[[rule]]
priority = 0

View file

@ -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) = &current_stop {
let restore_result = sysfs::write_sysfs_value(&stop_path, prev_stop);
if let Err(re) = restore_result {
warn!(
"Failed to restore previous stop threshold for battery '{}': {}. Battery may be in an inconsistent state.",
battery.name, re
);
} else {
debug!(
"Restored previous stop threshold ({}) for battery '{}'",
prev_stop, battery.name
);
}
}
errors.push(format!(
"Failed to set start threshold for {} battery '{}': {}",
battery.pattern.description, battery.name, e
));
}
}
} else if let Err(e) = stop_result {
errors.push(format!(
"Failed to set stop threshold for {} battery '{}': {}",
battery.pattern.description, battery.name, e
));
}
}
if success_count > 0 {
if !errors.is_empty() {
warn!(
"Partial success setting battery thresholds: {}",
errors.join("; ")
);
}
Ok(())
} else {
Err(ControlError::WriteError(format!(
"Failed to set charge thresholds on any battery: {}",
errors.join("; ")
)))
}
}
/// Determines if a power supply entry is a battery
fn is_battery(path: &Path) -> Result<bool> {
let type_path = path.join("type");
if !type_path.exists() {
return Ok(false);
}
let ps_type = sysfs::read_sysfs_value(&type_path).map_err(|e| {
ControlError::ReadError(format!("Failed to read {}: {}", type_path.display(), e))
})?;
Ok(ps_type == "Battery")
}
/// Identifies if a battery supports threshold control and which pattern it uses
fn find_battery_with_threshold_support(ps_path: &Path) -> Option<SupportedBattery<'static>> {
for pattern in THRESHOLD_PATTERNS {
let start_threshold_path = ps_path.join(pattern.start_path);
let stop_threshold_path = ps_path.join(pattern.stop_path);
// Ensure both paths exist and are writable before considering this battery supported
if sysfs::path_exists_and_writable(&start_threshold_path)
&& sysfs::path_exists_and_writable(&stop_threshold_path)
{
return Some(SupportedBattery {
name: ps_path.file_name()?.to_string_lossy().to_string(),
pattern,
path: ps_path.to_path_buf(),
});
}
}
None
}

View file

@ -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")
}

View file

@ -1 +0,0 @@
pub mod debug;

297
src/config.rs Normal file
View 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)
}
}

View file

@ -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,
},
})
}

View file

@ -1,5 +0,0 @@
pub mod load;
pub mod types;
pub use load::*;
pub use types::*;

View file

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

View file

@ -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 { pub struct SystemInfo {
// Overall system details // Overall system details
pub cpu_model: String, pub cpu_model: String,
pub architecture: String,
pub linux_distribution: String,
} }
pub struct CpuCoreInfo { pub struct CpuCoreInfo {
// Per-core data // Per-core data
pub core_id: u32, 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 temperature_celsius: Option<f32>,
} }
pub struct CpuGlobalInfo { pub struct CpuGlobalInfo {
// System-wide CPU settings // System-wide CPU settings
pub current_governor: Option<String>, pub epp: Option<String>, // Energy Performance Preference
pub available_governors: Vec<String>, pub epb: Option<String>, // Energy Performance Bias
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 average_temperature_celsius: Option<f32>, // Average temperature across all cores pub average_temperature_celsius: Option<f32>, // Average temperature across all cores
} }

1151
src/cpu.rs

File diff suppressed because it is too large Load diff

View file

@ -1,671 +1,262 @@
use crate::config::{AppConfig, LogLevel}; use std::{
use crate::core::SystemReport; collections::VecDeque,
use crate::engine; ops,
use crate::monitor; sync::{
use crate::util::error::{AppError, ControlError}; Arc,
use log::{LevelFilter, debug, error, info, warn}; atomic::{AtomicBool, Ordering},
use std::collections::VecDeque; },
use std::fs::File; time::{Duration, Instant},
use std::io::Write; };
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// Parameters for computing optimal polling interval use anyhow::Context;
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,
}
/// 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: Linear interpolation from 1.0 to 2.0
/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes)) /// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes))
fn idle_multiplier(idle_secs: u64) -> f32 { fn idle_multiplier(idle_for: Duration) -> f64 {
if idle_secs == 0 { let factor = match idle_for.as_secs() < 120 {
return 1.0; // No idle time, no multiplier effect // Less than 2 minutes.
}
let idle_factor = if idle_secs < 120 {
// Less than 2 minutes (0 to 119 seconds)
// Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s) // Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s)
1.0 + (idle_secs as f32) / 120.0 true => (idle_for.as_secs() as f64) / 120.0,
} else {
// 2 minutes (120 seconds) or more // 2 minutes or more.
let idle_time_minutes = idle_secs / 60;
// Logarithmic scaling: 1.0 + log2(minutes) // 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 // Clamp the multiplier to avoid excessive intervals.
idle_factor.min(5.0) // max factor of 5x (1.0 + factor).clamp(1.0, 5.0)
} }
/// Calculate optimal polling interval based on system conditions and history struct Daemon {
/// /// Last time when there was user activity.
/// 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
last_user_activity: Instant, last_user_activity: Instant,
/// Previous battery percentage (to calculate discharge rate)
last_battery_percentage: Option<f32>, /// The last computed polling interval.
/// Timestamp of last battery reading last_polling_interval: Option<Duration>,
last_battery_timestamp: Option<Instant>,
/// Battery discharge rate (%/hour) /// Whether if we are charging right now.
battery_discharge_rate: Option<f32>, charging: bool,
/// Time spent in each system state
state_durations: std::collections::HashMap<SystemState, Duration>, /// CPU usage and temperature log.
/// Last time a state transition happened cpu_log: VecDeque<CpuLog>,
last_state_change: Instant,
/// Current system state /// Power supply status log.
current_state: SystemState, power_supply_log: VecDeque<PowerSupplyLog>,
/// Last computed optimal polling interval
last_computed_interval: Option<u64>,
} }
impl Default for SystemHistory { struct CpuLog {
fn default() -> Self { at: Instant,
Self {
cpu_usage_history: VecDeque::new(), /// CPU usage between 0-1, a percentage.
temperature_history: VecDeque::new(), usage: f64,
last_user_activity: Instant::now(),
last_battery_percentage: None, /// CPU temperature in celcius.
last_battery_timestamp: None, temperature: f64,
battery_discharge_rate: None,
state_durations: std::collections::HashMap::new(),
last_state_change: Instant::now(),
current_state: SystemState::default(),
last_computed_interval: None,
}
}
} }
impl SystemHistory { struct CpuVolatility {
/// Update system history with new report data at: ops::Range<Instant>,
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 { usage: f64,
if let Some(usage) = core.usage_percent {
total_usage += usage;
core_count += 1;
}
}
if core_count > 0 { temperature: f64,
let avg_usage = total_usage / core_count as f32; }
// Keep only the last 5 measurements impl Daemon {
if self.cpu_usage_history.len() >= 5 { fn cpu_volatility(&self) -> Option<CpuVolatility> {
self.cpu_usage_history.pop_front(); if self.cpu_log.len() < 2 {
} return None;
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");
}
}
} }
// Update temperature history let change_count = self.cpu_log.len() - 1;
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 let mut usage_change_sum = 0.0;
if self.temperature_history.len() > 1 { let mut temperature_change_sum = 0.0;
let temp_change =
temp - self.temperature_history[self.temperature_history.len() - 2]; for index in 0..change_count {
if temp_change > 5.0 { let usage_change = self.cpu_log[index + 1].usage - self.cpu_log[index].usage;
// 5°C rise in temperature usage_change_sum += usage_change.abs();
self.last_user_activity = Instant::now();
debug!("User activity detected based on temperature change"); let temperature_change =
} self.cpu_log[index + 1].temperature - self.cpu_log[index].temperature;
} temperature_change_sum += temperature_change.abs();
} }
// Update battery discharge rate Some(CpuVolatility {
if let Some(battery) = report.batteries.first() { at: self.cpu_log.front().unwrap().at..self.cpu_log.back().unwrap().at,
// 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 { usage: usage_change_sum / change_count as f64,
let current_percent = f32::from(current_percentage); temperature: temperature_change_sum / change_count as f64,
})
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");
}
} }
/// Calculate CPU usage volatility (how much it's changing) fn is_cpu_idle(&self) -> bool {
fn get_cpu_volatility(&self) -> f32 { let recent_log_count = self
if self.cpu_usage_history.len() < 2 { .cpu_log
return 0.0; .iter()
} .rev()
.take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60))
.count();
let mut sum_of_changes = 0.0; if recent_log_count < 2 {
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; return false;
} }
// System considered idle if the average CPU usage of last readings is below 10% let recent_average = self
let recent_avg = .cpu_log
self.cpu_usage_history.iter().sum::<f32>() / self.cpu_usage_history.len() as f32; .iter()
recent_avg < 10.0 && self.get_cpu_volatility() < 5.0 .rev()
} .take(recent_log_count)
.map(|log| log.usage)
.sum::<f64>()
/ recent_log_count as f64;
/// Calculate optimal polling interval based on system conditions recent_average < 0.1
fn calculate_optimal_interval( && self
&self, .cpu_volatility()
config: &AppConfig, .is_none_or(|volatility| volatility.usage < 0.05)
on_battery: bool, }
) -> Result<u64, ControlError> { }
let params = IntervalParams {
base_interval: config.daemon.poll_interval_sec, struct PowerSupplyLog {
min_interval: config.daemon.min_poll_interval_sec, at: Instant,
max_interval: config.daemon.max_poll_interval_sec,
cpu_volatility: self.get_cpu_volatility(), /// Charge 0-1, as a percentage.
temp_volatility: self.get_temperature_volatility(), charge: f64,
battery_discharge_rate: self.battery_discharge_rate, }
last_user_activity: self.last_user_activity.elapsed(),
is_system_idle: self.is_system_idle(), impl Daemon {
on_battery, /// 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(&params, 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 pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> {
/// Returns Ok if configuration is valid, Err with a descriptive message if invalid log::info!("starting daemon...");
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})"
)))
}
}
/// Run the daemon let cancelled = Arc::new(AtomicBool::new(false));
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
};
// Get the appropriate level filter let cancelled_ = Arc::clone(&cancelled);
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
ctrlc::set_handler(move || { ctrlc::set_handler(move || {
info!("Received shutdown signal, exiting..."); log::info!("received shutdown signal");
r.store(false, Ordering::SeqCst); 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!( while !cancelled.load(Ordering::SeqCst) {}
"Daemon initialized with poll interval: {}s",
config.daemon.poll_interval_sec
);
// Set up stats file if configured log::info!("exiting...");
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(&current_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)?;
Ok(()) 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
View 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(&current_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
}

View file

@ -1,9 +1,7 @@
use crate::battery;
use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings}; use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings};
use crate::core::{OperationalMode, SystemReport, TurboSetting}; use crate::core::{OperationalMode, SystemReport};
use crate::cpu::{self}; use crate::cpu::{self};
use crate::util::error::{ControlError, EngineError}; use crate::power_supply;
use log::{debug, info, warn};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@ -120,30 +118,14 @@ impl TurboHysteresis {
/// 1. Try to apply a feature setting /// 1. Try to apply a feature setting
/// 2. If not supported, log a warning and continue /// 2. If not supported, log a warning and continue
/// 3. If other error, propagate the error /// 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, feature_name: &str,
value_description: &str, value_description: &str,
apply_fn: F, apply_fn: F,
) -> Result<(), EngineError> ) -> anyhow::Result<()> {
where log::info!("Setting {feature_name} to '{value_description}'");
F: FnOnce() -> Result<T, ControlError>,
{
info!("Setting {feature_name} to '{value_description}'");
match apply_fn() { 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))
}
}
}
} }
/// Determines the appropriate CPU profile based on power status or forced mode, /// Determines the appropriate CPU profile based on power status or forced mode,
@ -152,19 +134,19 @@ pub fn determine_and_apply_settings(
report: &SystemReport, report: &SystemReport,
config: &AppConfig, config: &AppConfig,
force_mode: Option<OperationalMode>, force_mode: Option<OperationalMode>,
) -> Result<(), EngineError> { ) -> anyhow::Result<()> {
// First, check if there's a governor override set // // First, check if there's a governor override set
if let Some(override_governor) = cpu::get_governor_override() { // if let Some(override_governor) = cpu::get_governor_override() {
info!( // log::info!(
"Governor override is active: '{}'. Setting governor.", // "Governor override is active: '{}'. Setting governor.",
override_governor.trim() // override_governor.trim()
); // );
// Apply the override governor setting // // Apply the override governor setting
try_apply_feature("override governor", override_governor.trim(), || { // try_apply_feature("override governor", override_governor.trim(), || {
cpu::set_governor(override_governor.trim(), None) // cpu::set_governor(override_governor.trim(), None)
})?; // })?;
} // }
// Determine AC/Battery status once, early in the function // Determine AC/Battery status once, early in the function
// For desktops (no batteries), we should always use the AC power profile // 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 { if let Some(mode) = force_mode {
match mode { match mode {
OperationalMode::Powersave => { OperationalMode::Powersave => {
info!("Forced Powersave mode selected. Applying 'battery' profile."); log::info!("Forced Powersave mode selected. Applying 'battery' profile.");
selected_profile_config = &config.battery; selected_profile_config = &config.battery;
} }
OperationalMode::Performance => { OperationalMode::Performance => {
info!("Forced Performance mode selected. Applying 'charger' profile."); log::info!("Forced Performance mode selected. Applying 'charger' profile.");
selected_profile_config = &config.charger; selected_profile_config = &config.charger;
} }
} }
} else { } else {
// Use the previously computed on_ac_power value // Use the previously computed on_ac_power value
if on_ac_power { if on_ac_power {
info!("On AC power, selecting Charger profile."); log::info!("On AC power, selecting Charger profile.");
selected_profile_config = &config.charger; selected_profile_config = &config.charger;
} else { } else {
info!("On Battery power, selecting Battery profile."); log::info!("On Battery power, selecting Battery profile.");
selected_profile_config = &config.battery; selected_profile_config = &config.battery;
} }
} }
// Apply settings from selected_profile_config // Apply settings from selected_profile_config
if let Some(governor) = &selected_profile_config.governor { if let Some(governor) = &selected_profile_config.governor {
info!("Setting governor to '{governor}'"); log::info!("Setting governor to '{governor}'");
// Let set_governor handle the validation for cpu in cpu::Cpu::all()? {
if let Err(e) = cpu::set_governor(governor, None) { // Let set_governor handle the validation
// If the governor is not available, log a warning if let Err(error) = cpu.set_governor(governor) {
if matches!(e, ControlError::InvalidGovernor(_)) // If the governor is not available, log a warning
|| matches!(e, ControlError::NotSupported(_)) log::warn!("{error}");
{
warn!(
"Configured governor '{governor}' is not available on this system. Skipping."
);
} else {
return Err(e.into());
} }
} }
} }
if let Some(turbo_setting) = selected_profile_config.turbo { 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 { match turbo_setting {
TurboSetting::Auto => { TurboSetting::Auto => {
if selected_profile_config.enable_auto_turbo { 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)?; manage_auto_turbo(report, selected_profile_config, on_ac_power)?;
} else { } else {
debug!( log::debug!(
"Superfreq's dynamic turbo management is disabled by configuration. Ensuring system uses its default behavior for automatic turbo control." "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. // 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 { if let Some(min_freq) = selected_profile_config.min_freq_mhz {
try_apply_feature("min frequency", &format!("{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 { if let Some(max_freq) = selected_profile_config.max_freq_mhz {
try_apply_feature("max frequency", &format!("{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; let stop_threshold = thresholds.stop;
if start_threshold < stop_threshold && stop_threshold <= 100 { if start_threshold < stop_threshold && stop_threshold <= 100 {
info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%"); log::info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%");
match battery::set_battery_charge_thresholds(start_threshold, stop_threshold) { match power_supply::set_battery_charge_thresholds(start_threshold, stop_threshold) {
Ok(()) => debug!("Battery charge thresholds set successfully"), Ok(()) => log::debug!("Battery charge thresholds set successfully"),
Err(e) => warn!("Failed to set battery charge thresholds: {e}"), Err(e) => log::warn!("Failed to set battery charge thresholds: {e}"),
} }
} else { } else {
warn!( log::warn!(
"Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}" "Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}"
); );
} }
} }
debug!("Profile settings applied successfully."); log::debug!("Profile settings applied successfully.");
Ok(()) Ok(())
} }
@ -298,7 +274,7 @@ fn manage_auto_turbo(
report: &SystemReport, report: &SystemReport,
config: &ProfileConfig, config: &ProfileConfig,
on_ac_power: bool, on_ac_power: bool,
) -> Result<(), EngineError> { ) -> anyhow::Result<()> {
// Get the auto turbo settings from the config // Get the auto turbo settings from the config
let turbo_settings = &config.turbo_auto_settings; 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) { let enable_turbo = match (cpu_temp, avg_cpu_usage, previous_turbo_enabled) {
// If temperature is too high, disable turbo regardless of load // If temperature is too high, disable turbo regardless of load
(Some(temp), _, _) if temp >= turbo_settings.temp_threshold_high => { (Some(temp), _, _) if temp >= turbo_settings.temp_threshold_high => {
info!( log::info!(
"Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)", "Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)",
temp, turbo_settings.temp_threshold_high temp,
turbo_settings.temp_threshold_high
); );
false false
} }
// If load is high enough, enable turbo (unless temp already caused it to disable) // If load is high enough, enable turbo (unless temp already caused it to disable)
(_, Some(usage), _) if usage >= turbo_settings.load_threshold_high => { (_, Some(usage), _) if usage >= turbo_settings.load_threshold_high => {
info!( log::info!(
"Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)", "Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)",
usage, turbo_settings.load_threshold_high usage,
turbo_settings.load_threshold_high
); );
true true
} }
// If load is low, disable turbo // If load is low, disable turbo
(_, Some(usage), _) if usage <= turbo_settings.load_threshold_low => { (_, Some(usage), _) if usage <= turbo_settings.load_threshold_low => {
info!( log::info!(
"Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)", "Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)",
usage, turbo_settings.load_threshold_low usage,
turbo_settings.load_threshold_low
); );
false false
} }
@ -376,7 +355,7 @@ fn manage_auto_turbo(
if usage > turbo_settings.load_threshold_low if usage > turbo_settings.load_threshold_low
&& usage < turbo_settings.load_threshold_high => && usage < turbo_settings.load_threshold_high =>
{ {
info!( log::info!(
"Auto Turbo: Maintaining previous state ({}) due to intermediate load ({:.1}%)", "Auto Turbo: Maintaining previous state ({}) due to intermediate load ({:.1}%)",
if prev_state { "enabled" } else { "disabled" }, if prev_state { "enabled" } else { "disabled" },
usage usage
@ -386,7 +365,7 @@ fn manage_auto_turbo(
// When CPU load data is present but temperature is missing, use the same hysteresis logic // When CPU load data is present but temperature is missing, use the same hysteresis logic
(None, Some(usage), prev_state) => { (None, Some(usage), prev_state) => {
info!( log::info!(
"Auto Turbo: Maintaining previous state ({}) due to missing temperature data (load: {:.1}%)", "Auto Turbo: Maintaining previous state ({}) due to missing temperature data (load: {:.1}%)",
if prev_state { "enabled" } else { "disabled" }, if prev_state { "enabled" } else { "disabled" },
usage usage
@ -396,7 +375,7 @@ fn manage_auto_turbo(
// When all metrics are missing, maintain the previous state // When all metrics are missing, maintain the previous state
(None, None, prev_state) => { (None, None, prev_state) => {
info!( log::info!(
"Auto Turbo: Maintaining previous state ({}) due to missing all CPU metrics", "Auto Turbo: Maintaining previous state ({}) due to missing all CPU metrics",
if prev_state { "enabled" } else { "disabled" } 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 // Any other cases with partial metrics, maintain previous state for stability
(_, _, prev_state) => { (_, _, prev_state) => {
info!( log::info!(
"Auto Turbo: Maintaining previous state ({}) due to incomplete CPU metrics", "Auto Turbo: Maintaining previous state ({}) due to incomplete CPU metrics",
if prev_state { "enabled" } else { "disabled" } if prev_state { "enabled" } else { "disabled" }
); );
@ -429,7 +408,7 @@ fn manage_auto_turbo(
TurboSetting::Never TurboSetting::Never
}; };
info!( log::info!(
"Auto Turbo: Applying turbo change from {} to {}", "Auto Turbo: Applying turbo change from {} to {}",
if previous_turbo_enabled { if previous_turbo_enabled {
"enabled" "enabled"
@ -441,7 +420,7 @@ fn manage_auto_turbo(
match cpu::set_turbo(turbo_setting) { match cpu::set_turbo(turbo_setting) {
Ok(()) => { Ok(()) => {
debug!( log::debug!(
"Auto Turbo: Successfully set turbo to {}", "Auto Turbo: Successfully set turbo to {}",
if enable_turbo { "enabled" } else { "disabled" } if enable_turbo { "enabled" } else { "disabled" }
); );
@ -450,7 +429,7 @@ fn manage_auto_turbo(
Err(e) => Err(EngineError::ControlError(e)), Err(e) => Err(EngineError::ControlError(e)),
} }
} else { } else {
debug!( log::debug!(
"Auto Turbo: Maintaining turbo state ({}) - no change needed", "Auto Turbo: Maintaining turbo state ({}) - no change needed",
if enable_turbo { "enabled" } else { "disabled" } if enable_turbo { "enabled" } else { "disabled" }
); );

65
src/fs.rs Normal file
View 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(),
)
})
}

View file

@ -1,476 +1,121 @@
mod battery;
mod cli;
mod config;
mod core;
mod cpu; mod cpu;
mod power_supply;
mod system;
mod fs;
mod config;
// mod core;
mod daemon; mod daemon;
mod engine; // mod engine;
mod monitor; // mod monitor;
mod util;
use crate::config::AppConfig; use anyhow::Context;
use crate::core::{GovernorOverrideMode, TurboSetting}; use clap::Parser as _;
use crate::util::error::{AppError, ControlError}; use std::fmt::Write as _;
use clap::{Parser, value_parser}; use std::io::Write as _;
use env_logger::Builder; use std::path::PathBuf;
use log::{debug, error, info}; use std::{io, process};
use std::error::Error; use yansi::Paint as _;
use std::sync::Once;
#[derive(Parser, Debug)] #[derive(clap::Parser, Debug)]
#[clap(author, version, about, long_about = None)] #[clap(author, version, about)]
struct Cli { struct Cli {
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)] #[clap(subcommand)]
command: Option<Commands>, command: Command,
} }
#[derive(Parser, Debug)] #[derive(clap::Parser, Debug)]
enum Commands { enum Command {
/// Display current system information /// Display information.
Info, Info,
/// Run as a daemon in the background
Daemon { /// Start the daemon.
#[clap(long)] Start {
verbose: bool, /// The daemon config path.
}, #[arg(long, env = "WATT_CONFIG")]
/// Set CPU governor config: PathBuf,
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,
}, },
/// Modify CPU attributes.
CpuSet(config::CpuDelta),
/// Modify power supply attributes.
PowerSet(config::PowerDelta),
} }
fn main() -> Result<(), AppError> { fn real_main() -> anyhow::Result<()> {
// Initialize logger once for the entire application
init_logger();
let cli = Cli::parse(); let cli = Cli::parse();
// Load configuration first, as it might be needed by the monitor module yansi::whenever(yansi::Condition::TTY_AND_COLOR);
// E.g., for ignored power supplies
let config = match config::load_config() { env_logger::Builder::new()
Ok(cfg) => cfg, .filter_level(cli.verbosity.log_level_filter())
Err(e) => { .format_timestamp(None)
error!("Error loading configuration: {e}. Using default values."); .format_module_path(false)
// Proceed with default config if loading fails .init();
AppConfig::default()
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 { let mut err = io::stderr();
// 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);
println!("\n{separator}"); let mut message = String::new();
let mut chain = error.chain().rev().peekable();
// Calculate centering while let Some(error) = chain.next() {
println!("{title}"); let _ = write!(
err,
println!("{separator}"); "{header} ",
}; header = if chain.peek().is_none() {
"error:"
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})"
)))
} else { } else {
info!( "cause:"
"Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%"
);
battery::set_battery_charge_thresholds(start_threshold, stop_threshold)
.map_err(AppError::Control)
} }
} .red()
Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose), .bold(),
Some(Commands::Debug) => cli::debug::run_debug(&config), );
None => {
info!("Welcome to superfreq! Use --help for commands.");
debug!("Current effective configuration: {config:?}");
Ok(())
}
};
if let Err(e) = command_result { String::clear(&mut message);
error!("Error executing command: {e}"); let _ = write!(message, "{error}");
if let Some(source) = e.source() {
error!("Caused by: {source}");
}
// Check for permission denied errors let mut chars = message.char_indices();
if let AppError::Control(control_error) = &e {
if matches!(control_error, ControlError::PermissionDenied(_)) { let _ = match (chars.next(), chars.next()) {
error!( (Some((_, first)), Some((second_start, second))) if second.is_lowercase() => {
"Hint: This operation may require administrator privileges (e.g., run with sudo)." writeln!(
); err,
"{first_lowercase}{rest}",
first_lowercase = first.to_lowercase(),
rest = &message[second_start..],
)
} }
}
std::process::exit(1); _ => {
writeln!(err, "{message}")
}
};
} }
Ok(()) process::exit(1);
}
/// 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(())
}
} }

View file

@ -1,8 +1,5 @@
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport}; use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport};
use crate::cpu::get_logical_core_count;
use crate::util::error::SysMonitorError;
use log::debug;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs, fs,
@ -13,163 +10,13 @@ use std::{
time::SystemTime, 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 { pub fn get_system_info() -> SystemInfo {
let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string()); 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 { SystemInfo { cpu_model }
cpu_model,
architecture,
linux_distribution,
}
} }
#[derive(Debug, Clone, Copy)] pub fn get_cpu_core_info(core_id: u32) -> anyhow::Result<CpuCoreInfo> {
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. // Temperature detection.
// Should be generic enough to be able to support for multiple hardware sensors // Should be generic enough to be able to support for multiple hardware sensors
// with the possibility of extending later down the road. // with the possibility of extending later down the road.
@ -260,31 +107,8 @@ pub fn get_cpu_core_info(
} }
} }
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 { Ok(CpuCoreInfo {
core_id, core_id,
current_frequency_mhz,
min_frequency_mhz,
max_frequency_mhz,
usage_percent,
temperature_celsius, temperature_celsius,
}) })
} }
@ -359,373 +183,7 @@ fn get_fallback_temperature(hw_path: &Path) -> Option<f32> {
None None
} }
pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> { pub fn get_cpu_model() -> anyhow::Result<String> {
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> {
let path = Path::new("/proc/cpuinfo"); let path = Path::new("/proc/cpuinfo");
let content = fs::read_to_string(path).map_err(|_| { let content = fs::read_to_string(path).map_err(|_| {
SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display())) SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display()))
@ -743,45 +201,3 @@ pub fn get_cpu_model() -> Result<String> {
"Could not find CPU model name in /proc/cpuinfo.".to_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
View 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")
}
}

127
src/system.rs Normal file
View file

@ -0,0 +1,127 @@
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 power_supplies: Vec<power_supply::PowerSupply>,
}
impl System {
pub fn new() -> anyhow::Result<Self> {
let mut system = Self {
is_ac: false,
cpus: Vec::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 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(())
}
}

View file

@ -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),
}

View file

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

View file

@ -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()
}