1
Fork 0
mirror of https://github.com/RGBCube/superfreq synced 2025-08-01 11:27:47 +00:00

Compare commits

...

58 commits

Author SHA1 Message Date
3e235c089b
daemon: merge cpu deltas 2025-06-05 00:09:41 +03:00
0e8b40227b
config: rename rule condition field 2025-06-04 23:50:21 +03:00
2c154cd589
expr: strict eval 2025-06-04 23:45:37 +03:00
917ed77255
config: fix serde 2025-06-04 21:51:34 +03:00
b6dd9e78d4
package: make multicall 2025-06-04 21:51:34 +03:00
a25ae59bde
package: rename to watt 2025-06-04 21:51:34 +03:00
008e05b726
cpu: use only recent cpu log entries for calculating volatility 2025-06-04 21:51:34 +03:00
7503e235a3
daemon: add eval 2025-06-04 21:51:34 +03:00
c2325fa5ed
daemon: set charging 2025-06-04 21:51:34 +03:00
1283e5be14
delete core.rs 2025-06-04 21:51:34 +03:00
ee7ea6b86d
cpu: add cpu info scanning 2025-06-04 21:51:34 +03:00
303a84479c
system: cpu temperatures scanning 2025-06-04 21:51:34 +03:00
421d4aaacc
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-06-04 21:51:34 +03:00
fd3ae29dc5
cpu: cache /proc/stat 2025-06-04 21:51:34 +03:00
2812baa77b
system: check for chassis type 31 and move power saving check below
Co-Authored-By: flashrun24 <flashrun42@gmail.com>
2025-06-04 21:51:34 +03:00
a343e38d95
system: is_ac 2025-06-04 21:51:34 +03:00
004b879672
power_supply: add more stuff and store them 2025-06-04 21:51:34 +03:00
4763b54c97
cpu: add usage percent 2025-06-04 21:51:34 +03:00
571f172cc2
cpu: add global turbo querying 2025-06-04 21:51:34 +03:00
07ca582760
cpu: set_ep{p,b} actually sets the attributes now 2025-06-04 21:51:34 +03:00
99feb831a8
cpu: store EPP and EPB 2025-06-04 21:51:34 +03:00
961d1dfcd7
cpu: store governor and available governors 2025-06-04 21:51:34 +03:00
d87237165b
cpu: store frequency 2025-06-04 21:51:34 +03:00
230967832b
monitor: delete old code 2025-06-04 21:51:34 +03:00
a514f1ba7a
main: use yansi::whenever 2025-06-04 21:51:34 +03:00
058ef997c6
cpu: add TODO 2025-06-04 21:51:34 +03:00
1ab9aceced
cpu: cpu times 2025-06-04 21:51:34 +03:00
137f801d2b
cpu&power: add more attributes 2025-06-04 21:51:34 +03:00
543e5a052e
fs: fix read() typesig 2025-06-04 21:51:34 +03:00
91cef3b8b1
cpu&power: share fs impls 2025-06-04 21:51:34 +03:00
c062327457
daemon: delete some old code and create daemon scaffold 2025-06-04 21:51:34 +03:00
4fa59b7ed4
daemon: implement polling_interval 2025-06-04 21:51:34 +03:00
606cedb68a
daemon: wip new impl 2025-06-04 21:51:34 +03:00
0de8105432
config: better more enhanched expression 2025-06-04 21:51:34 +03:00
f3813230c5
power_supply&cpu: kolor 2025-06-04 21:51:34 +03:00
b6d4e09c7f
power_supply&cpu: somewhat improve error messages 2025-06-04 21:51:34 +03:00
ca4b1dbc92
main: move application to deltas, comment out broken modules for now 2025-06-04 21:51:34 +03:00
c073b640dc
config: fix schema, toml does not have top level lists 2025-06-04 21:51:34 +03:00
0d3a88be03
config: nuke old config and implement a new system 2025-06-04 21:51:34 +03:00
2995909544
power_supply: rename is_battery to get_type and don't compare the type 2025-06-04 21:51:34 +03:00
2704379b42
power_supply: add derives to PowerSupply 2025-06-04 21:51:34 +03:00
0ed0f18bb3
main: delete historical logging code 2025-06-04 21:51:34 +03:00
a14d88cee7
wip unsound broken malfunctioning changes to make it compile 2025-06-04 21:51:34 +03:00
004e8e2a9c
cpu: impl Display for Cpu 2025-06-04 21:51:34 +03:00
6ef4da9113
power_supply&cpu: use objects 2025-06-04 21:51:34 +03:00
6377480312
power_supply: don't ignore non-batteries 2025-06-04 21:51:34 +03:00
baef8af981
cli: remove governor_persist 2025-06-04 21:51:34 +03:00
d0932ae78c
battery: clean up, rename to power_supply 2025-06-04 21:51:34 +03:00
87085f913b
cpu: clean up, clean main too 2025-06-04 21:51:34 +03:00
raf
da07011b02
Merge pull request #35 from spikespaz-contrib/u/jacob/flake-output-overlays
flake:  add superfreq and default pkgs to overlay
2025-05-31 23:13:51 +03:00
Jacob Birkett
0f3d5d81dd flake: devShells: use same pkgs as for packages output 2025-05-31 13:11:25 -07:00
raf
71cd443ba7
Merge pull request #34 from spikespaz-contrib/u/jacob/flake-formatter-alejandra
flake: formatter: set to alejandra
2025-05-31 22:54:03 +03:00
Jacob Birkett
3caaa22f3e flake: packages: inherit from default overlay 2025-05-31 12:46:22 -07:00
Jacob Birkett
08c51b6296 flake: overlays: add superfreq and default 2025-05-31 12:46:22 -07:00
Jacob Birkett
6b1af5cbab flake: pkgsForEach: replace legacyPackages with manual import 2025-05-31 12:46:22 -07:00
Jacob Birkett
7b375439bb flake: formatter: set to alejandra 2025-05-31 12:19:50 -07:00
raf
017793288a
Merge pull request #32 from NotAShelf/systems-input
flake: add aarch64-linux to systems
2025-05-21 15:53:08 +03:00
Bloxx12
55e04ea09e
flake: add aarch64-linux to systems 2025-05-21 14:36:24 +02:00
24 changed files with 2495 additions and 3816 deletions

174
Cargo.lock generated
View file

@ -95,6 +95,16 @@ dependencies = [
"clap_derive",
]
[[package]]
name = "clap-verbosity-flag"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84"
dependencies = [
"clap",
"log",
]
[[package]]
name = "clap_builder"
version = "4.5.38"
@ -131,6 +141,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctrlc"
version = "3.4.7"
@ -142,24 +161,25 @@ dependencies = [
]
[[package]]
name = "dirs"
version = "6.0.0"
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"dirs-sys",
"derive_more-impl",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
"convert_case",
"proc-macro2",
"quote",
"syn",
"unicode-xid",
]
[[package]]
@ -191,17 +211,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.15.3"
@ -220,6 +229,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
[[package]]
name = "indexmap"
version = "2.9.0"
@ -230,6 +245,17 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "is-terminal"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi 0.5.1",
"libc",
"windows-sys",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -243,12 +269,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
"windows-sys",
]
[[package]]
@ -262,37 +286,12 @@ dependencies = [
"syn",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "log"
version = "0.4.27"
@ -323,7 +322,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"hermit-abi 0.3.9",
"libc",
]
@ -333,12 +332,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "portable-atomic"
version = "1.11.0"
@ -372,17 +365,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_users"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.11.1"
@ -447,23 +429,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "superfreq"
version = "0.3.2"
dependencies = [
"anyhow",
"clap",
"ctrlc",
"dirs",
"env_logger",
"jiff",
"log",
"num_cpus",
"serde",
"thiserror",
"toml",
]
[[package]]
name = "syn"
version = "2.0.101"
@ -542,6 +507,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -549,10 +526,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
name = "watt"
version = "0.4.0"
dependencies = [
"anyhow",
"clap",
"clap-verbosity-flag",
"ctrlc",
"derive_more",
"env_logger",
"log",
"num_cpus",
"serde",
"thiserror",
"toml",
"yansi",
]
[[package]]
name = "windows-sys"
@ -635,3 +624,12 @@ checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
dependencies = [
"is-terminal",
]

View file

@ -1,20 +1,21 @@
[package]
name = "superfreq"
name = "watt"
description = "Modern CPU frequency and power management utility for Linux"
version = "0.3.2"
version = "0.4.0"
edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>"]
authors = ["NotAShelf <raf@notashelf.dev>", "RGBCube <git@rgbcu.be>"]
rust-version = "1.85"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
dirs = "6.0"
clap = { version = "4.0", features = ["derive"] }
num_cpus = "1.16"
ctrlc = "3.4"
log = "0.4"
env_logger = "0.11"
thiserror = "2.0"
anyhow = "1.0"
jiff = "0.2.13"
clap = { version = "4.0", features = ["derive", "env"] }
clap-verbosity-flag = "3.0.2"
ctrlc = "3.4"
derive_more = { version = "2.0.1", features = ["full"] }
env_logger = "0.11"
log = "0.4"
num_cpus = "1.16"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"
toml = "0.8"
yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] }

51
build.rs Normal file
View file

@ -0,0 +1,51 @@
use std::env;
use std::fs;
use std::path::PathBuf;
const MULTICALL_NAMES: &[&str] = &["cpu", "power"];
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=build.rs");
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let target = out_dir
.parent() // target/debug/build/<pkg>-<hash>/out
.and_then(|p| p.parent()) // target/debug/build/<pkg>-<hash>
.and_then(|p| p.parent()) // target/debug/
.ok_or("failed to find target directory")?;
let main_binary_name = env::var("CARGO_PKG_NAME")?;
let main_binary_path = target.join(&main_binary_name);
let mut errored = false;
for name in MULTICALL_NAMES {
let hardlink_path = target.join(name);
if hardlink_path.exists() {
if hardlink_path.is_dir() {
fs::remove_dir_all(&hardlink_path)?;
} else {
fs::remove_file(&hardlink_path)?;
}
}
if let Err(error) = fs::hard_link(&main_binary_path, &hardlink_path) {
println!(
"cargo:warning=failed to create hard link '{path}': {error}",
path = hardlink_path.display(),
);
errored = true;
}
}
if errored {
println!(
"cargo:warning=this often happens because the target binary isn't built yet, try running `cargo build` again"
);
println!("cargo:warning=keep in mind that this is for development purposes only");
}
Ok(())
}

3
config.toml Normal file
View file

@ -0,0 +1,3 @@
[[rule]]
priority = 0
if = { value = "%cpu-usage", is-more-than = 0.7 }

View file

@ -6,21 +6,38 @@
nixpkgs,
...
} @ inputs: let
forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux"];
pkgsForEach = nixpkgs.legacyPackages;
forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"];
pkgsForEach = forAllSystems (system:
import nixpkgs {
localSystem.system = system;
overlays = [self.overlays.default];
});
in {
packages = forAllSystems (system: {
superfreq = pkgsForEach.${system}.callPackage ./nix/package.nix {};
default = self.packages.${system}.superfreq;
});
overlays = {
superfreq = final: _: {
superfreq = final.callPackage ./nix/package.nix {};
};
default = self.overlays.superfreq;
};
devShells = forAllSystems (system: {
default = pkgsForEach.${system}.callPackage ./nix/shell.nix {};
});
packages =
nixpkgs.lib.mapAttrs (system: pkgs: {
inherit (pkgs) superfreq;
default = self.packages.${system}.superfreq;
})
pkgsForEach;
devShells =
nixpkgs.lib.mapAttrs (system: pkgs: {
default = pkgs.callPackage ./nix/shell.nix {};
})
pkgsForEach;
nixosModules = {
superfreq = import ./nix/module.nix inputs;
default = self.nixosModules.superfreq;
};
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra);
};
}

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;

500
src/config.rs Normal file
View file

@ -0,0 +1,500 @@
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(())
}
}
macro_rules! named {
($variant:ident => $value:literal) => {
pub mod $variant {
pub fn serialize<S: serde::Serializer>(serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str($value)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<(), D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = ();
fn expecting(&self, writer: &mut std::fmt::Formatter) -> std::fmt::Result {
writer.write_str(concat!("\"", $value, "\""))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
if value != $value {
return Err(E::invalid_value(serde::de::Unexpected::Str(value), &self));
}
Ok(())
}
}
deserializer.deserialize_str(Visitor)
}
}
};
}
mod expression {
named!(cpu_usage => "%cpu-usage");
named!(cpu_usage_volatility => "$cpu-usage-volatility");
named!(cpu_temperature => "$cpu-temperature");
named!(cpu_temperature_volatility => "$cpu-temperature-volatility");
named!(cpu_idle_seconds => "$cpu-idle-seconds");
named!(power_supply_charge => "%power-supply-charge");
named!(power_supply_discharge_rate => "%power-supply-discharge-rate");
named!(discharging => "?discharging");
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum Expression {
#[serde(with = "expression::cpu_usage")]
CpuUsage,
#[serde(with = "expression::cpu_usage_volatility")]
CpuUsageVolatility,
#[serde(with = "expression::cpu_temperature")]
CpuTemperature,
#[serde(with = "expression::cpu_temperature_volatility")]
CpuTemperatureVolatility,
#[serde(with = "expression::cpu_idle_seconds")]
CpuIdleSeconds,
#[serde(with = "expression::power_supply_charge")]
PowerSupplyCharge,
#[serde(with = "expression::power_supply_discharge_rate")]
PowerSupplyDischargeRate,
#[serde(with = "expression::discharging")]
Discharging,
Boolean(bool),
Number(f64),
Plus {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "plus")]
b: Box<Expression>,
},
Minus {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "minus")]
b: Box<Expression>,
},
Multiply {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "multiply")]
b: Box<Expression>,
},
Power {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "power")]
b: Box<Expression>,
},
Divide {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "divide")]
b: Box<Expression>,
},
LessThan {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "is-less-than")]
b: Box<Expression>,
},
MoreThan {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "is-more-than")]
b: Box<Expression>,
},
Equal {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "is-equal")]
b: Box<Expression>,
leeway: Box<Expression>,
},
And {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "and")]
b: Box<Expression>,
},
All {
all: Vec<Expression>,
},
Or {
#[serde(rename = "value")]
a: Box<Expression>,
#[serde(rename = "or")]
b: Box<Expression>,
},
Any {
any: Vec<Expression>,
},
Not {
not: Box<Expression>,
},
}
impl Default for Expression {
fn default() -> Self {
Self::Boolean(true)
}
}
impl Expression {
pub fn as_number(&self) -> anyhow::Result<f64> {
let Self::Number(number) = self else {
bail!("tried to cast '{self:?}' to a number, failed")
};
Ok(*number)
}
pub fn as_boolean(&self) -> anyhow::Result<bool> {
let Self::Boolean(boolean) = self else {
bail!("tried to cast '{self:?}' to a boolean, failed")
};
Ok(*boolean)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EvalState {
pub cpu_usage: f64,
pub cpu_usage_volatility: Option<f64>,
pub cpu_temperature: f64,
pub cpu_temperature_volatility: Option<f64>,
pub cpu_idle_seconds: f64,
pub power_supply_charge: f64,
pub power_supply_discharge_rate: Option<f64>,
pub discharging: bool,
}
impl Expression {
pub fn eval(&self, state: &EvalState) -> anyhow::Result<Option<Expression>> {
use Expression::*;
macro_rules! try_ok {
($expression:expr) => {
match $expression {
Some(value) => value,
None => return Ok(None),
}
};
}
macro_rules! eval {
($expression:expr) => {
try_ok!($expression.eval(state)?)
};
}
// [e8dax09]: This may be look inefficient, and it definitely isn't optimal,
// but expressions in rules are usually so small that it doesn't matter or
// make a perceiveable performance difference.
//
// We also want to be strict, instead of lazy in binary operations, because
// we want to catch type errors immediately.
//
// FIXME: We currently cannot catch errors that will happen when propagating None.
// You can have a type error go uncaught on first startup by using $cpu-usage-volatility
// incorrectly, for example.
Ok(Some(match self {
CpuUsage => Number(state.cpu_usage),
CpuUsageVolatility => Number(try_ok!(state.cpu_usage_volatility)),
CpuTemperature => Number(state.cpu_temperature),
CpuTemperatureVolatility => Number(try_ok!(state.cpu_temperature_volatility)),
CpuIdleSeconds => Number(state.cpu_idle_seconds),
PowerSupplyCharge => Number(state.cpu_idle_seconds),
PowerSupplyDischargeRate => Number(try_ok!(state.power_supply_discharge_rate)),
Discharging => Boolean(state.discharging),
literal @ (Boolean(_) | Number(_)) => literal.clone(),
Plus { a, b } => Number(eval!(a).as_number()? + eval!(b).as_number()?),
Minus { a, b } => Number(eval!(a).as_number()? - eval!(b).as_number()?),
Multiply { a, b } => Number(eval!(a).as_number()? * eval!(b).as_number()?),
Power { a, b } => Number(eval!(a).as_number()?.powf(eval!(b).as_number()?)),
Divide { a, b } => Number(eval!(a).as_number()? / eval!(b).as_number()?),
LessThan { a, b } => Boolean(eval!(a).as_number()? < eval!(b).as_number()?),
MoreThan { a, b } => Boolean(eval!(a).as_number()? > eval!(b).as_number()?),
Equal { a, b, leeway } => {
let a = eval!(a).as_number()?;
let b = eval!(b).as_number()?;
let leeway = eval!(leeway).as_number()?;
let minimum = a - leeway;
let maximum = a + leeway;
Boolean(minimum < b && b < maximum)
}
And { a, b } => {
let a = eval!(a).as_boolean()?;
let b = eval!(b).as_boolean()?;
Boolean(a && b)
}
All { all } => {
let mut result = true;
for value in all {
let value = eval!(value).as_boolean()?;
result = result && value;
}
Boolean(result)
}
Or { a, b } => {
let a = eval!(a).as_boolean()?;
let b = eval!(b).as_boolean()?;
Boolean(a || b)
}
Any { any } => {
let mut result = false;
for value in any {
let value = eval!(value).as_boolean()?;
result = result || value;
}
Boolean(result)
}
Not { not } => Boolean(!eval!(not).as_boolean()?),
}))
}
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Rule {
pub priority: u8,
#[serde(default, rename = "if", skip_serializing_if = "is_default")]
pub condition: Expression,
#[serde(default, skip_serializing_if = "is_default")]
pub cpu: CpuDelta,
#[serde(default, skip_serializing_if = "is_default")]
pub power: PowerDelta,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct DaemonConfig {
#[serde(rename = "rule")]
pub 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 mut config: Self = toml::from_str(&contents)
.with_context(|| format!("failed to parse file at '{path}'", path = path.display(),))?;
{
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);
}
}
config.rules.sort_by_key(|rule| rule.priority);
log::debug!("loaded config: {config:#?}");
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,89 +0,0 @@
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
pub enum TurboSetting {
Always, // turbo is forced on (if possible)
Auto, // system or driver controls turbo
Never, // turbo is forced off
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum GovernorOverrideMode {
Performance,
Powersave,
Reset,
}
impl fmt::Display for GovernorOverrideMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Performance => write!(f, "performance"),
Self::Powersave => write!(f, "powersave"),
Self::Reset => write!(f, "reset"),
}
}
}
pub struct SystemInfo {
// Overall system details
pub cpu_model: String,
pub architecture: String,
pub linux_distribution: String,
}
pub struct CpuCoreInfo {
// Per-core data
pub core_id: u32,
pub current_frequency_mhz: Option<u32>,
pub min_frequency_mhz: Option<u32>,
pub max_frequency_mhz: Option<u32>,
pub usage_percent: Option<f32>,
pub temperature_celsius: Option<f32>,
}
pub struct CpuGlobalInfo {
// System-wide CPU settings
pub current_governor: Option<String>,
pub available_governors: Vec<String>,
pub turbo_status: Option<bool>, // true for enabled, false for disabled
pub epp: Option<String>, // Energy Performance Preference
pub epb: Option<String>, // Energy Performance Bias
pub platform_profile: Option<String>,
pub average_temperature_celsius: Option<f32>, // Average temperature across all cores
}
pub struct BatteryInfo {
// Battery status (AC connected, charging state, capacity, power rate, charge start/stop thresholds if available).
pub name: String,
pub ac_connected: bool,
pub charging_state: Option<String>, // e.g., "Charging", "Discharging", "Full"
pub capacity_percent: Option<u8>,
pub power_rate_watts: Option<f32>, // positive for charging, negative for discharging
pub charge_start_threshold: Option<u8>,
pub charge_stop_threshold: Option<u8>,
}
pub struct SystemLoad {
// System load averages.
pub load_avg_1min: f32,
pub load_avg_5min: f32,
pub load_avg_15min: f32,
}
pub struct SystemReport {
// Now combine all the above for a snapshot of the system state.
pub system_info: SystemInfo,
pub cpu_cores: Vec<CpuCoreInfo>,
pub cpu_global: CpuGlobalInfo,
pub batteries: Vec<BatteryInfo>,
pub system_load: SystemLoad,
pub timestamp: std::time::SystemTime, // so we know when the report was generated
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationalMode {
Powersave,
Performance,
}

1188
src/cpu.rs

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

65
src/fs.rs Normal file
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,152 @@
mod battery;
mod cli;
mod config;
mod core;
mod cpu;
mod power_supply;
mod system;
mod fs;
mod config;
// mod core;
mod daemon;
mod engine;
mod monitor;
mod util;
// mod engine;
// mod monitor;
use crate::config::AppConfig;
use crate::core::{GovernorOverrideMode, TurboSetting};
use crate::util::error::{AppError, ControlError};
use clap::{Parser, value_parser};
use env_logger::Builder;
use log::{debug, error, info};
use std::error::Error;
use std::sync::Once;
use anyhow::Context;
use clap::Parser as _;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::PathBuf;
use std::{io, process};
use yansi::Paint as _;
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
#[derive(clap::Parser, Debug)]
#[clap(author, version, about)]
struct Cli {
#[clap(subcommand)]
command: Option<Commands>,
command: Command,
}
#[derive(Parser, Debug)]
enum Commands {
/// Display current system information
Info,
/// Run as a daemon in the background
Daemon {
#[clap(long)]
verbose: bool,
#[derive(clap::Parser, Debug)]
#[clap(multicall = true)]
enum Command {
/// Watt daemon.
Watt {
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
/// The daemon config path.
#[arg(long, env = "WATT_CONFIG")]
config: PathBuf,
},
/// Set CPU governor
SetGovernor {
governor: String,
#[clap(long)]
core_id: Option<u32>,
/// CPU metadata and modification utility.
Cpu {
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)]
command: CpuCommand,
},
/// 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,
/// Power supply metadata and modification utility.
Power {
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)]
command: PowerCommand,
},
}
fn main() -> Result<(), AppError> {
// Initialize logger once for the entire application
init_logger();
#[derive(clap::Parser, Debug)]
enum CpuCommand {
/// Modify CPU attributes.
Set(config::CpuDelta),
}
#[derive(clap::Parser, Debug)]
enum PowerCommand {
/// Modify power supply attributes.
Set(config::PowerDelta),
}
fn real_main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Load configuration first, as it might be needed by the monitor module
// E.g., for ignored power supplies
let config = match config::load_config() {
Ok(cfg) => cfg,
Err(e) => {
error!("Error loading configuration: {e}. Using default values.");
// Proceed with default config if loading fails
AppConfig::default()
yansi::whenever(yansi::Condition::TTY_AND_COLOR);
let (Command::Watt { verbosity, .. }
| Command::Cpu { verbosity, .. }
| Command::Power { verbosity, .. }) = cli.command;
env_logger::Builder::new()
.filter_level(verbosity.log_level_filter())
.format_timestamp(None)
.format_module_path(false)
.init();
match cli.command {
Command::Watt { config, .. } => {
let config =
config::DaemonConfig::load_from(&config).context("failed to load daemon config")?;
daemon::run(config)
}
Command::Cpu {
command: CpuCommand::Set(delta),
..
} => delta.apply(),
Command::Power {
command: PowerCommand::Set(delta),
..
} => delta.apply(),
}
}
fn main() {
let Err(error) = real_main() else {
return;
};
let command_result: Result<(), AppError> = match cli.command {
// TODO: This will be moved to a different module in the future.
Some(Commands::Info) => match monitor::collect_system_report(&config) {
Ok(report) => {
// Format section headers with proper centering
let format_section = |title: &str| {
let title_len = title.len();
let total_width = title_len + 8; // 8 is for padding (4 on each side)
let separator = "".repeat(total_width);
let mut err = io::stderr();
println!("\n{separator}");
let mut message = String::new();
let mut chain = error.chain().rev().peekable();
// Calculate centering
println!("{title}");
println!("{separator}");
};
format_section("System Information");
println!("CPU Model: {}", report.system_info.cpu_model);
println!("Architecture: {}", report.system_info.architecture);
println!(
"Linux Distribution: {}",
report.system_info.linux_distribution
);
// Format timestamp in a readable way
println!("Current Time: {}", jiff::Timestamp::now());
format_section("CPU Global Info");
println!(
"Current Governor: {}",
report
.cpu_global
.current_governor
.as_deref()
.unwrap_or("N/A")
);
println!(
"Available Governors: {}", // 21 length baseline
report.cpu_global.available_governors.join(", ")
);
println!(
"Turbo Status: {}",
match report.cpu_global.turbo_status {
Some(true) => "Enabled",
Some(false) => "Disabled",
None => "Unknown",
}
);
println!(
"EPP: {}",
report.cpu_global.epp.as_deref().unwrap_or("N/A")
);
println!(
"EPB: {}",
report.cpu_global.epb.as_deref().unwrap_or("N/A")
);
println!(
"Platform Profile: {}",
report
.cpu_global
.platform_profile
.as_deref()
.unwrap_or("N/A")
);
println!(
"CPU Temperature: {}",
report.cpu_global.average_temperature_celsius.map_or_else(
|| "N/A (No sensor detected)".to_string(),
|t| format!("{t:.1}°C")
)
);
format_section("CPU Core Info");
// Get max core ID length for padding
let max_core_id_len = report
.cpu_cores
.last()
.map_or(1, |core| core.core_id.to_string().len());
// Table headers
println!(
" {:>width$} │ {:^10} │ {:^10} │ {:^10} │ {:^7} │ {:^9}",
"Core",
"Current",
"Min",
"Max",
"Usage",
"Temp",
width = max_core_id_len + 4
);
println!(
" {:─>width$}──┼─{:─^10}─┼─{:─^10}─┼─{:─^10}─┼─{:─^7}─┼─{:─^9}",
"",
"",
"",
"",
"",
"",
width = max_core_id_len + 4
);
for core_info in &report.cpu_cores {
// Format frequencies: if current > max, show in a special way
let current_freq = match core_info.current_frequency_mhz {
Some(freq) => {
let max_freq = core_info.max_frequency_mhz.unwrap_or(0);
if freq > max_freq && max_freq > 0 {
// Special format for boosted frequencies
format!("{freq}*")
} else {
format!("{freq}")
}
}
None => "N/A".to_string(),
};
// CPU core display
println!(
" Core {:<width$} │ {:>10} │ {:>10} │ {:>10} │ {:>7} │ {:>9}",
core_info.core_id,
format!("{} MHz", current_freq),
format!(
"{} MHz",
core_info
.min_frequency_mhz
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
),
format!(
"{} MHz",
core_info
.max_frequency_mhz
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
),
format!(
"{}%",
core_info
.usage_percent
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
),
format!(
"{}°C",
core_info
.temperature_celsius
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
),
width = max_core_id_len
);
}
// Only display battery info for systems that have real batteries
// Skip this section entirely on desktop systems
if !report.batteries.is_empty() {
let has_real_batteries = report.batteries.iter().any(|b| {
// Check if any battery has actual battery data
// (as opposed to peripherals like wireless mice)
b.capacity_percent.is_some() || b.power_rate_watts.is_some()
});
if has_real_batteries {
format_section("Battery Info");
for battery_info in &report.batteries {
// Check if this appears to be a real system battery
if battery_info.capacity_percent.is_some()
|| battery_info.power_rate_watts.is_some()
{
let power_status = if battery_info.ac_connected {
"Connected to AC"
} else {
"Running on Battery"
};
println!("Battery {}:", battery_info.name);
println!(" Power Status: {power_status}");
println!(
" State: {}",
battery_info.charging_state.as_deref().unwrap_or("Unknown")
);
if let Some(capacity) = battery_info.capacity_percent {
println!(" Capacity: {capacity}%");
}
if let Some(power) = battery_info.power_rate_watts {
let direction = if power >= 0.0 {
"charging"
} else {
"discharging"
};
println!(
" Power Rate: {:.2} W ({})",
power.abs(),
direction
);
}
// Display charge thresholds if available
if battery_info.charge_start_threshold.is_some()
|| battery_info.charge_stop_threshold.is_some()
{
println!(
" Charge Thresholds: {}-{}",
battery_info
.charge_start_threshold
.map_or_else(|| "N/A".to_string(), |t| t.to_string()),
battery_info
.charge_stop_threshold
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
);
}
}
}
}
}
format_section("System Load");
println!(
"Load Average (1m): {:.2}",
report.system_load.load_avg_1min
);
println!(
"Load Average (5m): {:.2}",
report.system_load.load_avg_5min
);
println!(
"Load Average (15m): {:.2}",
report.system_load.load_avg_15min
);
Ok(())
}
Err(e) => Err(AppError::Monitor(e)),
},
Some(Commands::SetGovernor { governor, core_id }) => {
cpu::set_governor(&governor, core_id).map_err(AppError::Control)
}
Some(Commands::ForceGovernor { mode }) => {
cpu::force_governor(mode).map_err(AppError::Control)
}
Some(Commands::SetTurbo { setting }) => cpu::set_turbo(setting).map_err(AppError::Control),
Some(Commands::SetEpp { epp, core_id }) => {
cpu::set_epp(&epp, core_id).map_err(AppError::Control)
}
Some(Commands::SetEpb { epb, core_id }) => {
cpu::set_epb(&epb, core_id).map_err(AppError::Control)
}
Some(Commands::SetMinFreq { freq_mhz, core_id }) => {
// Basic validation for reasonable CPU frequency values
validate_freq(freq_mhz, "Minimum")?;
cpu::set_min_frequency(freq_mhz, core_id).map_err(AppError::Control)
}
Some(Commands::SetMaxFreq { freq_mhz, core_id }) => {
// Basic validation for reasonable CPU frequency values
validate_freq(freq_mhz, "Maximum")?;
cpu::set_max_frequency(freq_mhz, core_id).map_err(AppError::Control)
}
Some(Commands::SetPlatformProfile { profile }) => {
// Get available platform profiles and validate early if possible
match cpu::get_platform_profiles() {
Ok(available_profiles) => {
if available_profiles.contains(&profile) {
info!("Setting platform profile to '{profile}'");
cpu::set_platform_profile(&profile).map_err(AppError::Control)
} else {
error!(
"Invalid platform profile: '{}'. Available profiles: {}",
profile,
available_profiles.join(", ")
);
Err(AppError::Generic(format!(
"Invalid platform profile: '{}'. Available profiles: {}",
profile,
available_profiles.join(", ")
)))
}
}
Err(_e) => {
// If we can't get profiles (e.g., feature not supported), pass through to the function
cpu::set_platform_profile(&profile).map_err(AppError::Control)
}
}
}
Some(Commands::SetBatteryThresholds {
start_threshold,
stop_threshold,
}) => {
// We only need to check if start < stop since the range validation is handled by Clap
if start_threshold >= stop_threshold {
error!(
"Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})"
);
Err(AppError::Generic(format!(
"Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})"
)))
while let Some(error) = chain.next() {
let _ = write!(
err,
"{header} ",
header = if chain.peek().is_none() {
"error:"
} else {
info!(
"Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%"
);
battery::set_battery_charge_thresholds(start_threshold, stop_threshold)
.map_err(AppError::Control)
"cause:"
}
}
Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose),
Some(Commands::Debug) => cli::debug::run_debug(&config),
None => {
info!("Welcome to superfreq! Use --help for commands.");
debug!("Current effective configuration: {config:?}");
Ok(())
}
};
.red()
.bold(),
);
if let Err(e) = command_result {
error!("Error executing command: {e}");
if let Some(source) = e.source() {
error!("Caused by: {source}");
}
String::clear(&mut message);
let _ = write!(message, "{error}");
// Check for permission denied errors
if let AppError::Control(control_error) = &e {
if matches!(control_error, ControlError::PermissionDenied(_)) {
error!(
"Hint: This operation may require administrator privileges (e.g., run with sudo)."
);
let mut chars = message.char_indices();
let _ = match (chars.next(), chars.next()) {
(Some((_, first)), Some((second_start, second))) if second.is_lowercase() => {
writeln!(
err,
"{first_lowercase}{rest}",
first_lowercase = first.to_lowercase(),
rest = &message[second_start..],
)
}
}
std::process::exit(1);
_ => {
writeln!(err, "{message}")
}
};
}
Ok(())
}
/// Initialize the logger for the entire application
static LOGGER_INIT: Once = Once::new();
fn init_logger() {
LOGGER_INIT.call_once(|| {
// Set default log level based on environment or default to Info
let env_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
Builder::new()
.parse_filters(&env_log)
.format_timestamp(None)
.format_module_path(false)
.init();
debug!("Logger initialized with RUST_LOG={env_log}");
});
}
/// Validate CPU frequency input values
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), AppError> {
if freq_mhz == 0 {
error!("{label} frequency cannot be zero");
Err(AppError::Generic(format!(
"{label} frequency cannot be zero"
)))
} else if freq_mhz > 10000 {
// Extremely high value unlikely to be valid
error!("{label} frequency ({freq_mhz} MHz) is unreasonably high");
Err(AppError::Generic(format!(
"{label} frequency ({freq_mhz} MHz) is unreasonably high"
)))
} else {
Ok(())
}
process::exit(1);
}

View file

@ -1,787 +1,24 @@
use crate::config::AppConfig;
use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport};
use crate::cpu::get_logical_core_count;
use crate::util::error::SysMonitorError;
use log::debug;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
str::FromStr,
thread,
time::Duration,
time::SystemTime,
};
pub type Result<T, E = SysMonitorError> = std::result::Result<T, E>;
// Read a sysfs file to a string, trimming whitespace
fn read_sysfs_file_trimmed(path: impl AsRef<Path>) -> Result<String> {
fs::read_to_string(path.as_ref())
.map(|s| s.trim().to_string())
.map_err(|e| {
SysMonitorError::ReadError(format!("Path: {:?}, Error: {}", path.as_ref().display(), e))
})
}
// Read a sysfs file and parse it to a specific type
fn read_sysfs_value<T: FromStr>(path: impl AsRef<Path>) -> Result<T> {
let content = read_sysfs_file_trimmed(path.as_ref())?;
content.parse::<T>().map_err(|_| {
SysMonitorError::ParseError(format!(
"Could not parse '{}' from {:?}",
content,
path.as_ref().display()
))
})
}
pub fn get_system_info() -> SystemInfo {
let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string());
let linux_distribution = get_linux_distribution().unwrap_or_else(|_| "Unknown".to_string());
let architecture = std::env::consts::ARCH.to_string();
SystemInfo {
cpu_model,
architecture,
linux_distribution,
}
}
#[derive(Debug, Clone, Copy)]
pub struct CpuTimes {
user: u64,
nice: u64,
system: u64,
idle: u64,
iowait: u64,
irq: u64,
softirq: u64,
steal: u64,
}
impl CpuTimes {
const fn total_time(&self) -> u64 {
self.user
+ self.nice
+ self.system
+ self.idle
+ self.iowait
+ self.irq
+ self.softirq
+ self.steal
}
const fn idle_time(&self) -> u64 {
self.idle + self.iowait
}
}
fn read_all_cpu_times() -> Result<HashMap<u32, CpuTimes>> {
let content = fs::read_to_string("/proc/stat").map_err(SysMonitorError::Io)?;
let mut cpu_times_map = HashMap::new();
for line in content.lines() {
if line.starts_with("cpu") && line.chars().nth(3).is_some_and(|c| c.is_ascii_digit()) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 11 {
return Err(SysMonitorError::ProcStatParseError(format!(
"Line too short: {line}"
)));
}
let core_id_str = &parts[0][3..];
let core_id = core_id_str.parse::<u32>().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse core_id: {core_id_str}"
))
})?;
let times = CpuTimes {
user: parts[1].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse user time: {}",
parts[1]
))
})?,
nice: parts[2].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse nice time: {}",
parts[2]
))
})?,
system: parts[3].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse system time: {}",
parts[3]
))
})?,
idle: parts[4].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse idle time: {}",
parts[4]
))
})?,
iowait: parts[5].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse iowait time: {}",
parts[5]
))
})?,
irq: parts[6].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse irq time: {}",
parts[6]
))
})?,
softirq: parts[7].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse softirq time: {}",
parts[7]
))
})?,
steal: parts[8].parse().map_err(|_| {
SysMonitorError::ProcStatParseError(format!(
"Failed to parse steal time: {}",
parts[8]
))
})?,
};
cpu_times_map.insert(core_id, times);
}
}
Ok(cpu_times_map)
}
pub fn get_cpu_core_info(
core_id: u32,
prev_times: &CpuTimes,
current_times: &CpuTimes,
) -> Result<CpuCoreInfo> {
let cpufreq_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/"));
let current_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_cur_freq"))
.map(|khz| khz / 1000)
.ok();
let min_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_min_freq"))
.map(|khz| khz / 1000)
.ok();
let max_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_max_freq"))
.map(|khz| khz / 1000)
.ok();
// Temperature detection.
// Should be generic enough to be able to support for multiple hardware sensors
// with the possibility of extending later down the road.
let mut temperature_celsius: Option<f32> = None;
// Search for temperature in hwmon devices
if let Ok(hwmon_dir) = fs::read_dir("/sys/class/hwmon") {
for hw_entry in hwmon_dir.flatten() {
let hw_path = hw_entry.path();
// Check hwmon driver name
if let Ok(name) = read_sysfs_file_trimmed(hw_path.join("name")) {
// Intel CPU temperature driver
if name == "coretemp" {
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
temperature_celsius = Some(temp);
break;
}
}
// AMD CPU temperature driver
// TODO: 'zenergy' can also report those stats, I think?
else if name == "k10temp" || name == "zenpower" || name == "amdgpu" {
// AMD's k10temp doesn't always label cores individually
// First try to find core-specific temps
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Tdie") {
temperature_celsius = Some(temp);
break;
}
// Try Tctl temperature (CPU control temp)
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "Tctl") {
temperature_celsius = Some(temp);
break;
}
// Try CPU temperature
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "CPU") {
temperature_celsius = Some(temp);
break;
}
// Fall back to any available temperature input without a specific label
temperature_celsius = get_fallback_temperature(&hw_path);
if temperature_celsius.is_some() {
break;
}
}
// Other CPU temperature drivers
else if name.contains("cpu") || name.contains("temp") {
// Try to find a label that matches this core
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
temperature_celsius = Some(temp);
break;
}
// Fall back to any temperature reading if specific core not found
temperature_celsius = get_fallback_temperature(&hw_path);
if temperature_celsius.is_some() {
break;
}
}
}
}
}
// Try /sys/devices/platform paths for thermal zones as a last resort
if temperature_celsius.is_none() {
if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
for entry in thermal_zones.flatten() {
let zone_path = entry.path();
let name = entry.file_name().into_string().unwrap_or_default();
if name.starts_with("thermal_zone") {
// Try to match by type
if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
if zone_type.contains("cpu")
|| zone_type.contains("x86")
|| zone_type.contains("core")
{
if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
temperature_celsius = Some(temp_mc as f32 / 1000.0);
break;
}
}
}
}
}
}
}
let usage_percent: Option<f32> = {
let prev_idle = prev_times.idle_time();
let current_idle = current_times.idle_time();
let prev_total = prev_times.total_time();
let current_total = current_times.total_time();
let total_diff = current_total.saturating_sub(prev_total);
let idle_diff = current_idle.saturating_sub(prev_idle);
// Avoid division by zero if no time has passed or counters haven't changed
if total_diff == 0 {
None
} else {
let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32));
Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100
}
};
Ok(CpuCoreInfo {
core_id,
current_frequency_mhz,
min_frequency_mhz,
max_frequency_mhz,
usage_percent,
temperature_celsius,
})
}
/// Finds core-specific temperature
fn get_temperature_for_core(hw_path: &Path, core_id: u32, label_prefix: &str) -> Option<f32> {
for i in 1..=32 {
// Increased range to handle systems with many sensors
let label_path = hw_path.join(format!("temp{i}_label"));
let input_path = hw_path.join(format!("temp{i}_input"));
if label_path.exists() && input_path.exists() {
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
// Match various common label formats:
// "Core X", "core X", "Core-X", "CPU Core X", etc.
let core_pattern = format!("{label_prefix} {core_id}");
let alt_pattern = format!("{label_prefix}-{core_id}");
if label.eq_ignore_ascii_case(&core_pattern)
|| label.eq_ignore_ascii_case(&alt_pattern)
|| label
.to_lowercase()
.contains(&format!("core {core_id}").to_lowercase())
{
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
return Some(temp_mc as f32 / 1000.0);
}
}
}
}
}
None
}
// Finds generic sensor temperatures by label
fn get_generic_sensor_temperature(hw_path: &Path, label_name: &str) -> Option<f32> {
for i in 1..=32 {
let label_path = hw_path.join(format!("temp{i}_label"));
let input_path = hw_path.join(format!("temp{i}_input"));
if label_path.exists() && input_path.exists() {
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
if label.eq_ignore_ascii_case(label_name)
|| label.to_lowercase().contains(&label_name.to_lowercase())
{
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
return Some(temp_mc as f32 / 1000.0);
}
}
}
} else if !label_path.exists() && input_path.exists() {
// Some sensors might not have labels but still have valid temp inputs
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
return Some(temp_mc as f32 / 1000.0);
}
}
}
None
}
// Fallback to any temperature reading from a sensor
fn get_fallback_temperature(hw_path: &Path) -> Option<f32> {
for i in 1..=32 {
let input_path = hw_path.join(format!("temp{i}_input"));
if input_path.exists() {
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
return Some(temp_mc as f32 / 1000.0);
}
}
}
None
}
pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> {
let initial_cpu_times = read_all_cpu_times()?;
thread::sleep(Duration::from_millis(250)); // interval for CPU usage calculation
let final_cpu_times = read_all_cpu_times()?;
let num_cores = get_logical_core_count()
.map_err(|_| SysMonitorError::ReadError("Could not get the number of cores".to_string()))?;
let mut core_infos = Vec::with_capacity(num_cores as usize);
for core_id in 0..num_cores {
if let (Some(prev), Some(curr)) = (
initial_cpu_times.get(&core_id),
final_cpu_times.get(&core_id),
) {
match get_cpu_core_info(core_id, prev, curr) {
Ok(info) => core_infos.push(info),
Err(e) => {
// Log or handle error for a single core, maybe push a partial info or skip
eprintln!("Error getting info for core {core_id}: {e}");
}
}
} else {
// Log or handle missing times for a core
eprintln!("Missing CPU time data for core {core_id}");
}
}
Ok(core_infos)
}
pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo {
// Find a valid CPU to read global settings from
// Try cpu0 first, then fall back to any available CPU with cpufreq
let mut cpufreq_base_path_buf = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/");
if !cpufreq_base_path_buf.exists() {
let core_count = get_logical_core_count().unwrap_or_else(|e| {
eprintln!("Warning: {e}");
0
});
for i in 0..core_count {
let test_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"));
if test_path.exists() {
cpufreq_base_path_buf = test_path;
break; // Exit the loop as soon as we find a valid path
}
}
}
let turbo_status_path = Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo");
let boost_path = Path::new("/sys/devices/system/cpu/cpufreq/boost");
let current_governor = if cpufreq_base_path_buf.join("scaling_governor").exists() {
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_governor")).ok()
} else {
None
};
let available_governors = if cpufreq_base_path_buf
.join("scaling_available_governors")
.exists()
{
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_available_governors"))
.map_or_else(
|_| vec![],
|s| s.split_whitespace().map(String::from).collect(),
)
} else {
vec![]
};
let turbo_status = if turbo_status_path.exists() {
// 0 means turbo enabled, 1 means disabled for intel_pstate
read_sysfs_value::<u8>(turbo_status_path)
.map(|val| val == 0)
.ok()
} else if boost_path.exists() {
// 1 means turbo enabled, 0 means disabled for generic cpufreq boost
read_sysfs_value::<u8>(boost_path).map(|val| val == 1).ok()
} else {
None
};
// EPP (Energy Performance Preference)
let energy_perf_pref =
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_preference")).ok();
// EPB (Energy Performance Bias)
let energy_perf_bias =
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_bias")).ok();
let platform_profile = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile").ok();
// Calculate average CPU temperature from the core temperatures
let average_temperature_celsius = if cpu_cores.is_empty() {
None
} else {
// Filter cores with temperature readings, then calculate average
let cores_with_temp: Vec<&CpuCoreInfo> = cpu_cores
.iter()
.filter(|core| core.temperature_celsius.is_some())
.collect();
if cores_with_temp.is_empty() {
None
} else {
// Sum up all temperatures and divide by count
let sum: f32 = cores_with_temp
.iter()
.map(|core| core.temperature_celsius.unwrap())
.sum();
Some(sum / cores_with_temp.len() as f32)
}
};
// Return the constructed CpuGlobalInfo
CpuGlobalInfo {
current_governor,
available_governors,
turbo_status,
epp: energy_perf_pref,
epb: energy_perf_bias,
platform_profile,
average_temperature_celsius,
}
}
pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
let mut batteries = Vec::new();
let power_supply_path = Path::new("/sys/class/power_supply");
if !power_supply_path.exists() {
return Ok(batteries); // no power supply directory
}
let ignored_supplies = config.ignored_power_supplies.clone().unwrap_or_default();
// Determine overall AC connection status
let mut overall_ac_connected = false;
for entry in fs::read_dir(power_supply_path)? {
let entry = entry?;
let ps_path = entry.path();
let name = entry.file_name().into_string().unwrap_or_default();
// Check for AC adapter type (common names: AC, ACAD, ADP)
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
if ps_type == "Mains"
|| ps_type == "USB_PD_DRP"
|| ps_type == "USB_PD"
|| ps_type == "USB_DCP"
|| ps_type == "USB_CDP"
|| ps_type == "USB_ACA"
{
// USB types can also provide power
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
if online == 1 {
overall_ac_connected = true;
break;
}
}
}
} else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") {
// Fallback for type file missing
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
if online == 1 {
overall_ac_connected = true;
break;
}
}
}
}
// No AC adapter detected but we're on a desktop system
// Default to AC power for desktops
if !overall_ac_connected {
overall_ac_connected = is_likely_desktop_system();
}
for entry in fs::read_dir(power_supply_path)? {
let entry = entry?;
let ps_path = entry.path();
let name = entry.file_name().into_string().unwrap_or_default();
if ignored_supplies.contains(&name) {
continue;
}
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
if ps_type == "Battery" {
// Skip peripheral batteries that aren't real laptop batteries
if is_peripheral_battery(&ps_path, &name) {
debug!("Skipping peripheral battery: {name}");
continue;
}
let status_str = read_sysfs_file_trimmed(ps_path.join("status")).ok();
let capacity_percent = read_sysfs_value::<u8>(ps_path.join("capacity")).ok();
let power_rate_watts = if ps_path.join("power_now").exists() {
read_sysfs_value::<i32>(ps_path.join("power_now")) // uW
.map(|uw| uw as f32 / 1_000_000.0)
.ok()
} else if ps_path.join("current_now").exists()
&& ps_path.join("voltage_now").exists()
{
let current_ua = read_sysfs_value::<i32>(ps_path.join("current_now")).ok(); // uA
let voltage_uv = read_sysfs_value::<i32>(ps_path.join("voltage_now")).ok(); // uV
if let (Some(c), Some(v)) = (current_ua, voltage_uv) {
// Power (W) = (Voltage (V) * Current (A))
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
Some((f64::from(c) * f64::from(v) / 1_000_000_000_000.0) as f32)
} else {
None
}
} else {
None
};
let charge_start_threshold =
read_sysfs_value::<u8>(ps_path.join("charge_control_start_threshold")).ok();
let charge_stop_threshold =
read_sysfs_value::<u8>(ps_path.join("charge_control_end_threshold")).ok();
batteries.push(BatteryInfo {
name: name.clone(),
ac_connected: overall_ac_connected,
charging_state: status_str,
capacity_percent,
power_rate_watts,
charge_start_threshold,
charge_stop_threshold,
});
}
}
}
// If we found no batteries but have power supplies, we're likely on a desktop
if batteries.is_empty() && overall_ac_connected {
debug!("No laptop batteries found, likely a desktop system");
}
Ok(batteries)
}
/// Check if a battery is likely a peripheral (mouse, keyboard, etc) not a laptop battery
fn is_peripheral_battery(ps_path: &Path, name: &str) -> bool {
// Convert name to lowercase once for case-insensitive matching
let name_lower = name.to_lowercase();
// Common peripheral battery names
if name_lower.contains("mouse")
|| name_lower.contains("keyboard")
|| name_lower.contains("trackpad")
|| name_lower.contains("gamepad")
|| name_lower.contains("controller")
|| name_lower.contains("headset")
|| name_lower.contains("headphone")
{
return true;
}
// Small capacity batteries are likely not laptop batteries
if let Ok(energy_full) = read_sysfs_value::<i32>(ps_path.join("energy_full")) {
// Most laptop batteries are at least 20,000,000 µWh (20 Wh)
// Peripheral batteries are typically much smaller
if energy_full < 10_000_000 {
// 10 Wh in µWh
return true;
}
}
// Check for model name that indicates a peripheral
if let Ok(model) = read_sysfs_file_trimmed(ps_path.join("model_name")) {
if model.contains("bluetooth") || model.contains("wireless") {
return true;
}
}
false
}
/// Determine if this is likely a desktop system rather than a laptop
fn is_likely_desktop_system() -> bool {
// Check for DMI system type information
if let Ok(chassis_type) = fs::read_to_string("/sys/class/dmi/id/chassis_type") {
let chassis_type = chassis_type.trim();
// Chassis types:
// 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower
// 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One
// 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis
match chassis_type {
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => return true, // desktop form factors
"9" | "10" | "14" => return false, // laptop form factors
_ => {} // Unknown, continue with other checks
}
}
// Check CPU power policies, desktops often don't have these
let power_saving_exists = Path::new("/sys/module/intel_pstate/parameters/no_hwp").exists()
|| Path::new("/sys/devices/system/cpu/cpufreq/conservative").exists();
if !power_saving_exists {
return true; // likely a desktop
}
// Check battery-specific ACPI paths that laptops typically have
let laptop_acpi_paths = [
"/sys/class/power_supply/BAT0",
"/sys/class/power_supply/BAT1",
"/proc/acpi/battery",
];
for path in &laptop_acpi_paths {
if Path::new(path).exists() {
return false; // Likely a laptop
}
}
// Default to assuming desktop if we can't determine
true
}
pub fn get_system_load() -> Result<SystemLoad> {
let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?;
let parts: Vec<&str> = loadavg_str.split_whitespace().collect();
if parts.len() < 3 {
return Err(SysMonitorError::ParseError(
"Could not parse /proc/loadavg: expected at least 3 parts".to_string(),
));
}
let load_avg_1min = parts[0].parse().map_err(|_| {
SysMonitorError::ParseError(format!("Failed to parse 1min load: {}", parts[0]))
})?;
let load_avg_5min = parts[1].parse().map_err(|_| {
SysMonitorError::ParseError(format!("Failed to parse 5min load: {}", parts[1]))
})?;
let load_avg_15min = parts[2].parse().map_err(|_| {
SysMonitorError::ParseError(format!("Failed to parse 15min load: {}", parts[2]))
})?;
Ok(SystemLoad {
load_avg_1min,
load_avg_5min,
load_avg_15min,
})
}
pub fn collect_system_report(config: &AppConfig) -> Result<SystemReport> {
let system_info = get_system_info();
let cpu_cores = get_all_cpu_core_info()?;
let cpu_global = get_cpu_global_info(&cpu_cores);
let batteries = get_battery_info(config)?;
let system_load = get_system_load()?;
Ok(SystemReport {
system_info,
cpu_cores,
cpu_global,
batteries,
system_load,
timestamp: SystemTime::now(),
})
}
pub fn get_cpu_model() -> Result<String> {
let path = Path::new("/proc/cpuinfo");
let content = fs::read_to_string(path).map_err(|_| {
SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display()))
})?;
for line in content.lines() {
if line.starts_with("model name") {
if let Some(val) = line.split(':').nth(1) {
let cpu_model = val.trim().to_string();
return Ok(cpu_model);
}
}
}
Err(SysMonitorError::ParseError(
"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()
)))
}
// Try /sys/devices/platform paths for thermal zones as a last resort
// if temperature_celsius.is_none() {
// if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
// for entry in thermal_zones.flatten() {
// let zone_path = entry.path();
// let name = entry.file_name().into_string().unwrap_or_default();
// if name.starts_with("thermal_zone") {
// // Try to match by type
// if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
// if zone_type.contains("cpu")
// || zone_type.contains("x86")
// || zone_type.contains("core")
// {
// if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
// temperature_celsius = Some(temp_mc as f32 / 1000.0);
// break;
// }
// }
// }
// }
// }
// }
// }

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

238
src/system.rs Normal file
View file

@ -0,0 +1,238 @@
use std::{collections::HashMap, path::Path};
use anyhow::{Context, bail};
use crate::{cpu, fs, power_supply};
pub struct System {
pub is_ac: bool,
pub load_average_1min: f64,
pub load_average_5min: f64,
pub load_average_15min: f64,
pub cpus: Vec<cpu::Cpu>,
pub cpu_temperatures: HashMap<u32, f64>,
pub power_supplies: Vec<power_supply::PowerSupply>,
}
impl System {
pub fn new() -> anyhow::Result<Self> {
let mut system = Self {
is_ac: false,
cpus: Vec::new(),
cpu_temperatures: HashMap::new(),
power_supplies: Vec::new(),
load_average_1min: 0.0,
load_average_5min: 0.0,
load_average_15min: 0.0,
};
system.rescan()?;
Ok(system)
}
pub fn rescan(&mut self) -> anyhow::Result<()> {
self.cpus = cpu::Cpu::all().context("failed to scan CPUs")?;
self.power_supplies =
power_supply::PowerSupply::all().context("failed to scan power supplies")?;
self.is_ac = self
.power_supplies
.iter()
.any(|power_supply| power_supply.is_ac())
|| self.is_desktop()?;
self.rescan_load_average()?;
self.rescan_temperatures()?;
Ok(())
}
fn rescan_temperatures(&mut self) -> anyhow::Result<()> {
const PATH: &str = "/sys/class/hwmon";
let mut temperatures = HashMap::new();
for entry in fs::read_dir(PATH)
.with_context(|| format!("failed to read hardware information from '{PATH}'"))?
.with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))?
{
let entry = entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
let entry_path = entry.path();
let Some(name) = fs::read(entry_path.join("name")).with_context(|| {
format!(
"failed to read name of hardware entry at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
match &*name {
// TODO: 'zenergy' can also report those stats, I think?
"coretemp" | "k10temp" | "zenpower" | "amdgpu" => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
}
// Other CPU temperature drivers.
_ if name.contains("cpu") || name.contains("temp") => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
}
_ => {}
}
}
self.cpu_temperatures = temperatures;
Ok(())
}
fn get_temperatures(
device_path: &Path,
temperatures: &mut HashMap<u32, f64>,
) -> anyhow::Result<()> {
// Increased range to handle systems with many sensors.
for i in 1..=96 {
let label_path = device_path.join(format!("temp{i}_label"));
let input_path = device_path.join(format!("temp{i}_input"));
if !label_path.exists() || !input_path.exists() {
continue;
}
let Some(label) = fs::read(&label_path).with_context(|| {
format!(
"failed to read hardware hardware device label from '{path}'",
path = label_path.display(),
)
})?
else {
continue;
};
// Match various common label formats:
// "Core X", "core X", "Core-X", "CPU Core X", etc.
let number = label
.trim_start_matches("cpu")
.trim_start_matches("CPU")
.trim_start()
.trim_start_matches("core")
.trim_start_matches("Core")
.trim_start()
.trim_start_matches("tdie")
.trim_start_matches("Tdie")
.trim_start()
.trim_start_matches("tctl")
.trim_start_matches("Tctl")
.trim_start()
.trim_start_matches("-")
.trim();
let Ok(number) = number.parse::<u32>() else {
continue;
};
let Some(temperature_mc) = fs::read_n::<i64>(&input_path).with_context(|| {
format!(
"failed to read CPU temperature from '{path}'",
path = input_path.display(),
)
})?
else {
continue;
};
temperatures.insert(number, temperature_mc as f64 / 1000.0);
}
Ok(())
}
fn is_desktop(&mut self) -> anyhow::Result<bool> {
if let Some(chassis_type) =
fs::read("/sys/class/dmi/id/chassis_type").context("failed to read chassis type")?
{
// 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower,
// 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One,
// 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis,
// 31=Convertible Laptop
match chassis_type.trim() {
// Desktop form factors.
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => {
return Ok(true);
}
// Laptop form factors.
"9" | "10" | "14" | "31" => {
return Ok(false);
}
// Unknown, continue with other checks
_ => {}
}
}
// Check battery-specific ACPI paths that laptops typically have
let laptop_acpi_paths = [
"/sys/class/power_supply/BAT0",
"/sys/class/power_supply/BAT1",
"/proc/acpi/battery",
];
for path in laptop_acpi_paths {
if fs::exists(path) {
return Ok(false); // Likely a laptop.
}
}
// Check CPU power policies, desktops often don't have these
let power_saving_exists = fs::exists("/sys/module/intel_pstate/parameters/no_hwp")
|| fs::exists("/sys/devices/system/cpu/cpufreq/conservative");
if !power_saving_exists {
return Ok(true); // Likely a desktop.
}
// Default to assuming desktop if we can't determine.
Ok(true)
}
fn rescan_load_average(&mut self) -> anyhow::Result<()> {
let content = fs::read("/proc/loadavg")
.context("failed to read load average from '/proc/loadavg'")?
.context("'/proc/loadavg' doesn't exist, are you on linux?")?;
let mut parts = content.split_whitespace();
let (Some(load_average_1min), Some(load_average_5min), Some(load_average_15min)) =
(parts.next(), parts.next(), parts.next())
else {
bail!(
"failed to parse first 3 load average entries due to there not being enough, content: {content}"
);
};
self.load_average_1min = load_average_1min
.parse()
.context("failed to parse load average")?;
self.load_average_5min = load_average_5min
.parse()
.context("failed to parse load average")?;
self.load_average_15min = load_average_15min
.parse()
.context("failed to parse load average")?;
Ok(())
}
}

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