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

View file

@ -1,20 +1,21 @@
[package] [package]
name = "superfreq" name = "watt"
description = "Modern CPU frequency and power management utility for Linux" description = "Modern CPU frequency and power management utility for Linux"
version = "0.3.2" version = "0.4.0"
edition = "2024" edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>"] authors = ["NotAShelf <raf@notashelf.dev>", "RGBCube <git@rgbcu.be>"]
rust-version = "1.85" rust-version = "1.85"
[dependencies] [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" 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, nixpkgs,
... ...
} @ inputs: let } @ inputs: let
forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux"]; forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"];
pkgsForEach = nixpkgs.legacyPackages; pkgsForEach = forAllSystems (system:
import nixpkgs {
localSystem.system = system;
overlays = [self.overlays.default];
});
in { in {
packages = forAllSystems (system: { overlays = {
superfreq = pkgsForEach.${system}.callPackage ./nix/package.nix {}; superfreq = final: _: {
default = self.packages.${system}.superfreq; superfreq = final.callPackage ./nix/package.nix {};
}); };
default = self.overlays.superfreq;
};
devShells = forAllSystems (system: { packages =
default = pkgsForEach.${system}.callPackage ./nix/shell.nix {}; 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 = { nixosModules = {
superfreq = import ./nix/module.nix inputs; superfreq = import ./nix/module.nix inputs;
default = self.nixosModules.superfreq; 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,
}

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

65
src/fs.rs Normal file
View file

@ -0,0 +1,65 @@
use std::{error, fs, io, path::Path, str};
use anyhow::Context;
pub fn exists(path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
path.exists()
}
pub fn read_dir(path: impl AsRef<Path>) -> anyhow::Result<Option<fs::ReadDir>> {
let path = path.as_ref();
match fs::read_dir(path) {
Ok(entries) => Ok(Some(entries)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).context(format!(
"failed to read directory '{path}'",
path = path.display()
)),
}
}
pub fn read(path: impl AsRef<Path>) -> anyhow::Result<Option<String>> {
let path = path.as_ref();
match fs::read_to_string(path) {
Ok(string) => Ok(Some(string.trim().to_owned())),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).context(format!("failed to read '{path}", path = path.display())),
}
}
pub fn read_n<N: str::FromStr>(path: impl AsRef<Path>) -> anyhow::Result<Option<N>>
where
N::Err: error::Error + Send + Sync + 'static,
{
let path = path.as_ref();
match read(path)? {
Some(content) => Ok(Some(content.trim().parse().with_context(|| {
format!(
"failed to parse contents of '{path}' as a unsigned number",
path = path.display(),
)
})?)),
None => Ok(None),
}
}
pub fn write(path: impl AsRef<Path>, value: &str) -> anyhow::Result<()> {
let path = path.as_ref();
fs::write(path, value).with_context(|| {
format!(
"failed to write '{value}' to '{path}'",
path = path.display(),
)
})
}

View file

@ -1,476 +1,152 @@
mod battery;
mod cli;
mod config;
mod core;
mod cpu; mod cpu;
mod power_supply;
mod system;
mod fs;
mod config;
// mod core;
mod daemon; mod daemon;
mod engine; // mod engine;
mod monitor; // mod monitor;
mod util;
use crate::config::AppConfig; use anyhow::Context;
use crate::core::{GovernorOverrideMode, TurboSetting}; use clap::Parser as _;
use crate::util::error::{AppError, ControlError}; use std::fmt::Write as _;
use clap::{Parser, value_parser}; use std::io::Write as _;
use env_logger::Builder; use std::path::PathBuf;
use log::{debug, error, info}; use std::{io, process};
use std::error::Error; use yansi::Paint as _;
use std::sync::Once;
#[derive(Parser, Debug)] #[derive(clap::Parser, Debug)]
#[clap(author, version, about, long_about = None)] #[clap(author, version, about)]
struct Cli { struct Cli {
#[clap(subcommand)] #[clap(subcommand)]
command: Option<Commands>, command: Command,
} }
#[derive(Parser, Debug)] #[derive(clap::Parser, Debug)]
enum Commands { #[clap(multicall = true)]
/// Display current system information enum Command {
Info, /// Watt daemon.
/// Run as a daemon in the background Watt {
Daemon { #[command(flatten)]
#[clap(long)] verbosity: clap_verbosity_flag::Verbosity,
verbose: bool,
/// The daemon config path.
#[arg(long, env = "WATT_CONFIG")]
config: PathBuf,
}, },
/// Set CPU governor
SetGovernor { /// CPU metadata and modification utility.
governor: String, Cpu {
#[clap(long)] #[command(flatten)]
core_id: Option<u32>, verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)]
command: CpuCommand,
}, },
/// Force a specific governor mode persistently
ForceGovernor { /// Power supply metadata and modification utility.
/// Mode to force: performance, powersave, or reset Power {
#[clap(value_enum)] #[command(flatten)]
mode: GovernorOverrideMode, verbosity: clap_verbosity_flag::Verbosity,
},
/// Set turbo boost behavior #[clap(subcommand)]
SetTurbo { command: PowerCommand,
#[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,
}, },
} }
fn main() -> Result<(), AppError> { #[derive(clap::Parser, Debug)]
// Initialize logger once for the entire application enum CpuCommand {
init_logger(); /// 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(); let cli = Cli::parse();
// Load configuration first, as it might be needed by the monitor module yansi::whenever(yansi::Condition::TTY_AND_COLOR);
// E.g., for ignored power supplies
let config = match config::load_config() {
Ok(cfg) => cfg,
Err(e) => {
error!("Error loading configuration: {e}. Using default values.");
// Proceed with default config if loading fails
AppConfig::default()
}
};
let command_result: Result<(), AppError> = match cli.command { let (Command::Watt { verbosity, .. }
// TODO: This will be moved to a different module in the future. | Command::Cpu { verbosity, .. }
Some(Commands::Info) => match monitor::collect_system_report(&config) { | Command::Power { verbosity, .. }) = cli.command;
Ok(report) => {
// Format section headers with proper centering
let format_section = |title: &str| {
let title_len = title.len();
let total_width = title_len + 8; // 8 is for padding (4 on each side)
let separator = "".repeat(total_width);
println!("\n{separator}"); env_logger::Builder::new()
.filter_level(verbosity.log_level_filter())
// 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})"
)))
} 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)
}
}
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(())
}
};
if let Err(e) = command_result {
error!("Error executing command: {e}");
if let Some(source) = e.source() {
error!("Caused by: {source}");
}
// 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)."
);
}
}
std::process::exit(1);
}
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_timestamp(None)
.format_module_path(false) .format_module_path(false)
.init(); .init();
debug!("Logger initialized with RUST_LOG={env_log}"); match cli.command {
}); Command::Watt { config, .. } => {
let config =
config::DaemonConfig::load_from(&config).context("failed to load daemon config")?;
daemon::run(config)
} }
/// Validate CPU frequency input values Command::Cpu {
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), AppError> { command: CpuCommand::Set(delta),
if freq_mhz == 0 { ..
error!("{label} frequency cannot be zero"); } => delta.apply(),
Err(AppError::Generic(format!(
"{label} frequency cannot be zero" Command::Power {
))) command: PowerCommand::Set(delta),
} else if freq_mhz > 10000 { ..
// Extremely high value unlikely to be valid } => delta.apply(),
error!("{label} frequency ({freq_mhz} MHz) is unreasonably high"); }
Err(AppError::Generic(format!( }
"{label} frequency ({freq_mhz} MHz) is unreasonably high"
))) fn main() {
let Err(error) = real_main() else {
return;
};
let mut err = io::stderr();
let mut message = String::new();
let mut chain = error.chain().rev().peekable();
while let Some(error) = chain.next() {
let _ = write!(
err,
"{header} ",
header = if chain.peek().is_none() {
"error:"
} else { } else {
Ok(()) "cause:"
} }
.red()
.bold(),
);
String::clear(&mut message);
let _ = write!(message, "{error}");
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..],
)
}
_ => {
writeln!(err, "{message}")
}
};
}
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 // Try /sys/devices/platform paths for thermal zones as a last resort
if temperature_celsius.is_none() { // if temperature_celsius.is_none() {
if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") { // if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
for entry in thermal_zones.flatten() { // for entry in thermal_zones.flatten() {
let zone_path = entry.path(); // let zone_path = entry.path();
let name = entry.file_name().into_string().unwrap_or_default(); // let name = entry.file_name().into_string().unwrap_or_default();
if name.starts_with("thermal_zone") { // if name.starts_with("thermal_zone") {
// Try to match by type // // Try to match by type
if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) { // if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
if zone_type.contains("cpu") // if zone_type.contains("cpu")
|| zone_type.contains("x86") // || zone_type.contains("x86")
|| zone_type.contains("core") // || zone_type.contains("core")
{ // {
if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) { // if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
temperature_celsius = Some(temp_mc as f32 / 1000.0); // temperature_celsius = Some(temp_mc as f32 / 1000.0);
break; // 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()
)))
}

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