mirror of
https://github.com/RGBCube/superfreq
synced 2025-08-01 11:27:47 +00:00
Compare commits
58 commits
0358e0bece
...
3e235c089b
Author | SHA1 | Date | |
---|---|---|---|
3e235c089b | |||
0e8b40227b | |||
2c154cd589 | |||
917ed77255 | |||
b6dd9e78d4 | |||
a25ae59bde | |||
008e05b726 | |||
7503e235a3 | |||
c2325fa5ed | |||
1283e5be14 | |||
ee7ea6b86d | |||
303a84479c | |||
421d4aaacc | |||
fd3ae29dc5 | |||
2812baa77b | |||
a343e38d95 | |||
004b879672 | |||
4763b54c97 | |||
571f172cc2 | |||
07ca582760 | |||
99feb831a8 | |||
961d1dfcd7 | |||
d87237165b | |||
230967832b | |||
a514f1ba7a | |||
058ef997c6 | |||
1ab9aceced | |||
137f801d2b | |||
543e5a052e | |||
91cef3b8b1 | |||
c062327457 | |||
4fa59b7ed4 | |||
606cedb68a | |||
0de8105432 | |||
f3813230c5 | |||
b6d4e09c7f | |||
ca4b1dbc92 | |||
c073b640dc | |||
0d3a88be03 | |||
2995909544 | |||
2704379b42 | |||
0ed0f18bb3 | |||
a14d88cee7 | |||
004e8e2a9c | |||
6ef4da9113 | |||
6377480312 | |||
baef8af981 | |||
d0932ae78c | |||
87085f913b | |||
![]() |
da07011b02 | ||
![]() |
0f3d5d81dd | ||
![]() |
71cd443ba7 | ||
![]() |
3caaa22f3e | ||
![]() |
08c51b6296 | ||
![]() |
6b1af5cbab | ||
![]() |
7b375439bb | ||
![]() |
017793288a | ||
![]() |
55e04ea09e |
24 changed files with 2495 additions and 3816 deletions
174
Cargo.lock
generated
174
Cargo.lock
generated
|
@ -95,6 +95,16 @@ dependencies = [
|
|||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap-verbosity-flag"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.38"
|
||||
|
@ -131,6 +141,15 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.7"
|
||||
|
@ -142,24 +161,25 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys",
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -191,17 +211,6 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.3"
|
||||
|
@ -220,6 +229,12 @@ version = "0.3.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
|
@ -230,6 +245,17 @@ dependencies = [
|
|||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.1",
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
|
@ -243,12 +269,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -262,37 +286,12 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
|
@ -323,7 +322,7 @@ version = "1.16.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.3.9",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
@ -333,12 +332,6 @@ version = "1.21.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.0"
|
||||
|
@ -372,17 +365,6 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
|
@ -447,23 +429,6 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "superfreq"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"jiff",
|
||||
"log",
|
||||
"num_cpus",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.101"
|
||||
|
@ -542,6 +507,18 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
@ -549,10 +526,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
name = "watt"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"clap-verbosity-flag",
|
||||
"ctrlc",
|
||||
"derive_more",
|
||||
"env_logger",
|
||||
"log",
|
||||
"num_cpus",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
|
@ -635,3 +624,12 @@ checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
|
|||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
dependencies = [
|
||||
"is-terminal",
|
||||
]
|
||||
|
|
27
Cargo.toml
27
Cargo.toml
|
@ -1,20 +1,21 @@
|
|||
[package]
|
||||
name = "superfreq"
|
||||
name = "watt"
|
||||
description = "Modern CPU frequency and power management utility for Linux"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
||||
authors = ["NotAShelf <raf@notashelf.dev>", "RGBCube <git@rgbcu.be>"]
|
||||
rust-version = "1.85"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
dirs = "6.0"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
num_cpus = "1.16"
|
||||
ctrlc = "3.4"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
jiff = "0.2.13"
|
||||
clap = { version = "4.0", features = ["derive", "env"] }
|
||||
clap-verbosity-flag = "3.0.2"
|
||||
ctrlc = "3.4"
|
||||
derive_more = { version = "2.0.1", features = ["full"] }
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
num_cpus = "1.16"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "2.0"
|
||||
toml = "0.8"
|
||||
yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] }
|
||||
|
|
51
build.rs
Normal file
51
build.rs
Normal 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
3
config.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
[[rule]]
|
||||
priority = 0
|
||||
if = { value = "%cpu-usage", is-more-than = 0.7 }
|
35
flake.nix
35
flake.nix
|
@ -6,21 +6,38 @@
|
|||
nixpkgs,
|
||||
...
|
||||
} @ inputs: let
|
||||
forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux"];
|
||||
pkgsForEach = nixpkgs.legacyPackages;
|
||||
forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"];
|
||||
pkgsForEach = forAllSystems (system:
|
||||
import nixpkgs {
|
||||
localSystem.system = system;
|
||||
overlays = [self.overlays.default];
|
||||
});
|
||||
in {
|
||||
packages = forAllSystems (system: {
|
||||
superfreq = pkgsForEach.${system}.callPackage ./nix/package.nix {};
|
||||
default = self.packages.${system}.superfreq;
|
||||
});
|
||||
overlays = {
|
||||
superfreq = final: _: {
|
||||
superfreq = final.callPackage ./nix/package.nix {};
|
||||
};
|
||||
default = self.overlays.superfreq;
|
||||
};
|
||||
|
||||
devShells = forAllSystems (system: {
|
||||
default = pkgsForEach.${system}.callPackage ./nix/shell.nix {};
|
||||
});
|
||||
packages =
|
||||
nixpkgs.lib.mapAttrs (system: pkgs: {
|
||||
inherit (pkgs) superfreq;
|
||||
default = self.packages.${system}.superfreq;
|
||||
})
|
||||
pkgsForEach;
|
||||
|
||||
devShells =
|
||||
nixpkgs.lib.mapAttrs (system: pkgs: {
|
||||
default = pkgs.callPackage ./nix/shell.nix {};
|
||||
})
|
||||
pkgsForEach;
|
||||
|
||||
nixosModules = {
|
||||
superfreq = import ./nix/module.nix inputs;
|
||||
default = self.nixosModules.superfreq;
|
||||
};
|
||||
|
||||
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra);
|
||||
};
|
||||
}
|
||||
|
|
262
src/battery.rs
262
src/battery.rs
|
@ -1,262 +0,0 @@
|
|||
use crate::{config::types::BatteryChargeThresholds, util::error::ControlError, util::sysfs};
|
||||
use log::{debug, warn};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub type Result<T, E = ControlError> = std::result::Result<T, E>;
|
||||
|
||||
/// Represents a pattern of path suffixes used to control battery charge thresholds
|
||||
/// for different device vendors.
|
||||
#[derive(Clone)]
|
||||
pub struct ThresholdPathPattern {
|
||||
pub description: &'static str,
|
||||
pub start_path: &'static str,
|
||||
pub stop_path: &'static str,
|
||||
}
|
||||
|
||||
// Threshold patterns
|
||||
const THRESHOLD_PATTERNS: &[ThresholdPathPattern] = &[
|
||||
ThresholdPathPattern {
|
||||
description: "Standard",
|
||||
start_path: "charge_control_start_threshold",
|
||||
stop_path: "charge_control_end_threshold",
|
||||
},
|
||||
ThresholdPathPattern {
|
||||
description: "ASUS",
|
||||
start_path: "charge_control_start_percentage",
|
||||
stop_path: "charge_control_end_percentage",
|
||||
},
|
||||
// Combine Huawei and ThinkPad since they use identical paths
|
||||
ThresholdPathPattern {
|
||||
description: "ThinkPad/Huawei",
|
||||
start_path: "charge_start_threshold",
|
||||
stop_path: "charge_stop_threshold",
|
||||
},
|
||||
// Framework laptop support
|
||||
ThresholdPathPattern {
|
||||
description: "Framework",
|
||||
start_path: "charge_behaviour_start_threshold",
|
||||
stop_path: "charge_behaviour_end_threshold",
|
||||
},
|
||||
];
|
||||
|
||||
/// Represents a battery that supports charge threshold control
|
||||
pub struct SupportedBattery<'a> {
|
||||
pub name: String,
|
||||
pub pattern: &'a ThresholdPathPattern,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Set battery charge thresholds to protect battery health
|
||||
///
|
||||
/// This sets the start and stop charging thresholds for batteries that support this feature.
|
||||
/// Different laptop vendors implement battery thresholds in different ways, so this function
|
||||
/// attempts to handle multiple implementations (Lenovo, ASUS, etc.).
|
||||
///
|
||||
/// The thresholds determine at what percentage the battery starts charging (when below `start_threshold`)
|
||||
/// and at what percentage it stops (when it reaches `stop_threshold`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `start_threshold` - The battery percentage at which charging should start (typically 0-99)
|
||||
/// * `stop_threshold` - The battery percentage at which charging should stop (typically 1-100)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The thresholds are invalid (start >= stop or stop > 100)
|
||||
/// - No power supply path is found
|
||||
/// - No batteries with threshold support are found
|
||||
/// - Failed to set thresholds on any battery
|
||||
pub fn set_battery_charge_thresholds(start_threshold: u8, stop_threshold: u8) -> Result<()> {
|
||||
// Validate thresholds using `BatteryChargeThresholds`
|
||||
let thresholds =
|
||||
BatteryChargeThresholds::new(start_threshold, stop_threshold).map_err(|e| match e {
|
||||
crate::config::types::ConfigError::Validation(msg) => {
|
||||
ControlError::InvalidValueError(msg)
|
||||
}
|
||||
_ => ControlError::InvalidValueError(format!("Invalid battery threshold values: {e}")),
|
||||
})?;
|
||||
|
||||
let power_supply_path = Path::new("/sys/class/power_supply");
|
||||
if !power_supply_path.exists() {
|
||||
return Err(ControlError::NotSupported(
|
||||
"Power supply path not found, battery threshold control not supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// XXX: Skip checking directory writability since /sys is a virtual filesystem
|
||||
// Individual file writability will be checked by find_battery_with_threshold_support
|
||||
|
||||
let supported_batteries = find_supported_batteries(power_supply_path)?;
|
||||
if supported_batteries.is_empty() {
|
||||
return Err(ControlError::NotSupported(
|
||||
"No batteries with charge threshold control support found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
apply_thresholds_to_batteries(&supported_batteries, thresholds.start, thresholds.stop)
|
||||
}
|
||||
|
||||
/// Finds all batteries in the system that support threshold control
|
||||
fn find_supported_batteries(power_supply_path: &Path) -> Result<Vec<SupportedBattery<'static>>> {
|
||||
let entries = fs::read_dir(power_supply_path).map_err(|e| {
|
||||
if e.kind() == io::ErrorKind::PermissionDenied {
|
||||
ControlError::PermissionDenied(format!(
|
||||
"Permission denied accessing power supply directory: {}",
|
||||
power_supply_path.display()
|
||||
))
|
||||
} else {
|
||||
ControlError::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut supported_batteries = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to read power-supply entry: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let ps_path = entry.path();
|
||||
if is_battery(&ps_path)? {
|
||||
if let Some(battery) = find_battery_with_threshold_support(&ps_path) {
|
||||
supported_batteries.push(battery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if supported_batteries.is_empty() {
|
||||
warn!("No batteries with charge threshold support found");
|
||||
} else {
|
||||
debug!(
|
||||
"Found {} batteries with threshold support",
|
||||
supported_batteries.len()
|
||||
);
|
||||
for battery in &supported_batteries {
|
||||
debug!(
|
||||
"Battery '{}' supports {} threshold control",
|
||||
battery.name, battery.pattern.description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(supported_batteries)
|
||||
}
|
||||
|
||||
/// Applies the threshold settings to all supported batteries
|
||||
fn apply_thresholds_to_batteries(
|
||||
batteries: &[SupportedBattery<'_>],
|
||||
start_threshold: u8,
|
||||
stop_threshold: u8,
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
let mut success_count = 0;
|
||||
|
||||
for battery in batteries {
|
||||
let start_path = battery.path.join(battery.pattern.start_path);
|
||||
let stop_path = battery.path.join(battery.pattern.stop_path);
|
||||
|
||||
// Read current thresholds in case we need to restore them
|
||||
let current_stop = sysfs::read_sysfs_value(&stop_path).ok();
|
||||
|
||||
// Write stop threshold first (must be >= start threshold)
|
||||
let stop_result = sysfs::write_sysfs_value(&stop_path, &stop_threshold.to_string());
|
||||
|
||||
// Only proceed to set start threshold if stop threshold was set successfully
|
||||
if matches!(stop_result, Ok(())) {
|
||||
let start_result = sysfs::write_sysfs_value(&start_path, &start_threshold.to_string());
|
||||
|
||||
match start_result {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Set {}-{}% charge thresholds for {} battery '{}'",
|
||||
start_threshold, stop_threshold, battery.pattern.description, battery.name
|
||||
);
|
||||
success_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
// Start threshold failed, try to restore the previous stop threshold
|
||||
if let Some(prev_stop) = ¤t_stop {
|
||||
let restore_result = sysfs::write_sysfs_value(&stop_path, prev_stop);
|
||||
if let Err(re) = restore_result {
|
||||
warn!(
|
||||
"Failed to restore previous stop threshold for battery '{}': {}. Battery may be in an inconsistent state.",
|
||||
battery.name, re
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"Restored previous stop threshold ({}) for battery '{}'",
|
||||
prev_stop, battery.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(format!(
|
||||
"Failed to set start threshold for {} battery '{}': {}",
|
||||
battery.pattern.description, battery.name, e
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Err(e) = stop_result {
|
||||
errors.push(format!(
|
||||
"Failed to set stop threshold for {} battery '{}': {}",
|
||||
battery.pattern.description, battery.name, e
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if success_count > 0 {
|
||||
if !errors.is_empty() {
|
||||
warn!(
|
||||
"Partial success setting battery thresholds: {}",
|
||||
errors.join("; ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlError::WriteError(format!(
|
||||
"Failed to set charge thresholds on any battery: {}",
|
||||
errors.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines if a power supply entry is a battery
|
||||
fn is_battery(path: &Path) -> Result<bool> {
|
||||
let type_path = path.join("type");
|
||||
|
||||
if !type_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ps_type = sysfs::read_sysfs_value(&type_path).map_err(|e| {
|
||||
ControlError::ReadError(format!("Failed to read {}: {}", type_path.display(), e))
|
||||
})?;
|
||||
|
||||
Ok(ps_type == "Battery")
|
||||
}
|
||||
|
||||
/// Identifies if a battery supports threshold control and which pattern it uses
|
||||
fn find_battery_with_threshold_support(ps_path: &Path) -> Option<SupportedBattery<'static>> {
|
||||
for pattern in THRESHOLD_PATTERNS {
|
||||
let start_threshold_path = ps_path.join(pattern.start_path);
|
||||
let stop_threshold_path = ps_path.join(pattern.stop_path);
|
||||
|
||||
// Ensure both paths exist and are writable before considering this battery supported
|
||||
if sysfs::path_exists_and_writable(&start_threshold_path)
|
||||
&& sysfs::path_exists_and_writable(&stop_threshold_path)
|
||||
{
|
||||
return Some(SupportedBattery {
|
||||
name: ps_path.file_name()?.to_string_lossy().to_string(),
|
||||
pattern,
|
||||
path: ps_path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
265
src/cli/debug.rs
265
src/cli/debug.rs
|
@ -1,265 +0,0 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::cpu;
|
||||
use crate::monitor;
|
||||
use crate::util::error::AppError;
|
||||
use std::fs;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Prints comprehensive debug information about the system
|
||||
pub fn run_debug(config: &AppConfig) -> Result<(), AppError> {
|
||||
println!("=== SUPERFREQ DEBUG INFORMATION ===");
|
||||
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Current date and time
|
||||
println!("Timestamp: {}", jiff::Timestamp::now());
|
||||
|
||||
// Kernel information
|
||||
if let Ok(kernel_info) = get_kernel_info() {
|
||||
println!("Kernel Version: {kernel_info}");
|
||||
} else {
|
||||
println!("Kernel Version: Unable to determine");
|
||||
}
|
||||
|
||||
// System uptime
|
||||
if let Ok(uptime) = get_system_uptime() {
|
||||
println!(
|
||||
"System Uptime: {} hours, {} minutes",
|
||||
uptime.as_secs() / 3600,
|
||||
(uptime.as_secs() % 3600) / 60
|
||||
);
|
||||
} else {
|
||||
println!("System Uptime: Unable to determine");
|
||||
}
|
||||
|
||||
// Get system information
|
||||
match monitor::collect_system_report(config) {
|
||||
Ok(report) => {
|
||||
println!("\n--- SYSTEM INFORMATION ---");
|
||||
println!("CPU Model: {}", report.system_info.cpu_model);
|
||||
println!("Architecture: {}", report.system_info.architecture);
|
||||
println!(
|
||||
"Linux Distribution: {}",
|
||||
report.system_info.linux_distribution
|
||||
);
|
||||
|
||||
println!("\n--- CONFIGURATION ---");
|
||||
println!("Current Configuration: {config:#?}");
|
||||
|
||||
// Print important sysfs paths and whether they exist
|
||||
println!("\n--- SYSFS PATHS ---");
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/intel_pstate/no_turbo",
|
||||
"Intel P-State Turbo Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/cpufreq/boost",
|
||||
"Generic CPU Boost Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/devices/system/cpu/amd_pstate/cpufreq/boost",
|
||||
"AMD P-State Boost Control",
|
||||
);
|
||||
check_and_print_sysfs_path(
|
||||
"/sys/firmware/acpi/platform_profile",
|
||||
"ACPI Platform Profile Control",
|
||||
);
|
||||
check_and_print_sysfs_path("/sys/class/power_supply", "Power Supply Information");
|
||||
|
||||
println!("\n--- CPU INFORMATION ---");
|
||||
println!("Current Governor: {:?}", report.cpu_global.current_governor);
|
||||
println!(
|
||||
"Available Governors: {}",
|
||||
report.cpu_global.available_governors.join(", ")
|
||||
);
|
||||
println!("Turbo Status: {:?}", report.cpu_global.turbo_status);
|
||||
println!(
|
||||
"Energy Performance Preference (EPP): {:?}",
|
||||
report.cpu_global.epp
|
||||
);
|
||||
println!("Energy Performance Bias (EPB): {:?}", report.cpu_global.epb);
|
||||
|
||||
// Add governor override information
|
||||
if let Some(override_governor) = cpu::get_governor_override() {
|
||||
println!("Governor Override: {}", override_governor.trim());
|
||||
} else {
|
||||
println!("Governor Override: None");
|
||||
}
|
||||
|
||||
println!("\n--- PLATFORM PROFILE ---");
|
||||
println!(
|
||||
"Current Platform Profile: {:?}",
|
||||
report.cpu_global.platform_profile
|
||||
);
|
||||
match cpu::get_platform_profiles() {
|
||||
Ok(profiles) => println!("Available Platform Profiles: {}", profiles.join(", ")),
|
||||
Err(_) => println!("Available Platform Profiles: Not supported on this system"),
|
||||
}
|
||||
|
||||
println!("\n--- CPU CORES DETAIL ---");
|
||||
println!("Total CPU Cores: {}", report.cpu_cores.len());
|
||||
for core in &report.cpu_cores {
|
||||
println!("Core {}:", core.core_id);
|
||||
println!(
|
||||
" Current Frequency: {} MHz",
|
||||
core.current_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Min Frequency: {} MHz",
|
||||
core.min_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Max Frequency: {} MHz",
|
||||
core.max_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
);
|
||||
println!(
|
||||
" Usage: {}%",
|
||||
core.usage_percent
|
||||
.map_or_else(|| "N/A".to_string(), |u| format!("{u:.1}"))
|
||||
);
|
||||
println!(
|
||||
" Temperature: {}°C",
|
||||
core.temperature_celsius
|
||||
.map_or_else(|| "N/A".to_string(), |t| format!("{t:.1}"))
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n--- TEMPERATURE INFORMATION ---");
|
||||
println!(
|
||||
"Average CPU Temperature: {}",
|
||||
report.cpu_global.average_temperature_celsius.map_or_else(
|
||||
|| "N/A (CPU temperature sensor not detected)".to_string(),
|
||||
|t| format!("{t:.1}°C")
|
||||
)
|
||||
);
|
||||
|
||||
println!("\n--- BATTERY INFORMATION ---");
|
||||
if report.batteries.is_empty() {
|
||||
println!("No batteries found or all are ignored.");
|
||||
} else {
|
||||
for battery in &report.batteries {
|
||||
println!("Battery: {}", battery.name);
|
||||
println!(" AC Connected: {}", battery.ac_connected);
|
||||
println!(
|
||||
" Charging State: {}",
|
||||
battery.charging_state.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
" Capacity: {}%",
|
||||
battery
|
||||
.capacity_percent
|
||||
.map_or_else(|| "N/A".to_string(), |c| c.to_string())
|
||||
);
|
||||
println!(
|
||||
" Power Rate: {} W",
|
||||
battery
|
||||
.power_rate_watts
|
||||
.map_or_else(|| "N/A".to_string(), |p| format!("{p:.2}"))
|
||||
);
|
||||
println!(
|
||||
" Charge Start Threshold: {}",
|
||||
battery
|
||||
.charge_start_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
println!(
|
||||
" Charge Stop Threshold: {}",
|
||||
battery
|
||||
.charge_stop_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n--- SYSTEM LOAD ---");
|
||||
println!(
|
||||
"Load Average (1 min): {:.2}",
|
||||
report.system_load.load_avg_1min
|
||||
);
|
||||
println!(
|
||||
"Load Average (5 min): {:.2}",
|
||||
report.system_load.load_avg_5min
|
||||
);
|
||||
println!(
|
||||
"Load Average (15 min): {:.2}",
|
||||
report.system_load.load_avg_15min
|
||||
);
|
||||
|
||||
println!("\n--- DAEMON STATUS ---");
|
||||
// Simple check for daemon status - can be expanded later
|
||||
let daemon_status = fs::metadata("/var/run/superfreq.pid").is_ok();
|
||||
println!("Daemon Running: {daemon_status}");
|
||||
|
||||
// Check for systemd service status
|
||||
if let Ok(systemd_status) = is_systemd_service_active("superfreq") {
|
||||
println!("Systemd Service Active: {systemd_status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(AppError::Monitor(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get kernel version information
|
||||
fn get_kernel_info() -> Result<String, AppError> {
|
||||
let output = Command::new("uname")
|
||||
.arg("-r")
|
||||
.output()
|
||||
.map_err(AppError::Io)?;
|
||||
|
||||
let kernel_version = String::from_utf8(output.stdout)
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse kernel version: {e}")))?;
|
||||
Ok(kernel_version.trim().to_string())
|
||||
}
|
||||
|
||||
/// Get system uptime
|
||||
fn get_system_uptime() -> Result<Duration, AppError> {
|
||||
let uptime_str = fs::read_to_string("/proc/uptime").map_err(AppError::Io)?;
|
||||
let uptime_secs = uptime_str
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::Generic("Invalid format in /proc/uptime file".to_string()))?
|
||||
.parse::<f64>()
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse uptime from /proc/uptime: {e}")))?;
|
||||
|
||||
Ok(Duration::from_secs_f64(uptime_secs))
|
||||
}
|
||||
|
||||
/// Check if a sysfs path exists and print its status
|
||||
fn check_and_print_sysfs_path(path: &str, description: &str) {
|
||||
let exists = std::path::Path::new(path).exists();
|
||||
println!(
|
||||
"{}: {} ({})",
|
||||
description,
|
||||
path,
|
||||
if exists { "Exists" } else { "Not Found" }
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a systemd service is active
|
||||
fn is_systemd_service_active(service_name: &str) -> Result<bool, AppError> {
|
||||
let output = Command::new("systemctl")
|
||||
.arg("is-active")
|
||||
.arg(format!("{service_name}.service"))
|
||||
.stdout(Stdio::piped()) // capture stdout instead of letting it print
|
||||
.stderr(Stdio::null()) // redirect stderr to null
|
||||
.output()
|
||||
.map_err(AppError::Io)?;
|
||||
|
||||
// Check if the command executed successfully
|
||||
if !output.status.success() {
|
||||
// Command failed - service is either not found or not active
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Command executed successfully, now check the output content
|
||||
let status = String::from_utf8(output.stdout)
|
||||
.map_err(|e| AppError::Generic(format!("Failed to parse systemctl output: {e}")))?;
|
||||
|
||||
// Explicitly verify the output is "active"
|
||||
Ok(status.trim() == "active")
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
pub mod debug;
|
500
src/config.rs
Normal file
500
src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
// Configuration loading functionality
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::types::{AppConfig, AppConfigToml, ConfigError, DaemonConfig, ProfileConfig};
|
||||
|
||||
/// The primary function to load application configuration from a specific path or from default locations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `specific_path` - If provided, only attempts to load from this path and errors if not found
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(AppConfig)` - Successfully loaded configuration
|
||||
/// * `Err(ConfigError)` - Error loading or parsing configuration
|
||||
pub fn load_config() -> Result<AppConfig, ConfigError> {
|
||||
load_config_from_path(None)
|
||||
}
|
||||
|
||||
/// Load configuration from a specific path or try default paths
|
||||
pub fn load_config_from_path(specific_path: Option<&str>) -> Result<AppConfig, ConfigError> {
|
||||
// If a specific path is provided, only try that one
|
||||
if let Some(path_str) = specific_path {
|
||||
let path = Path::new(path_str);
|
||||
if path.exists() {
|
||||
return load_and_parse_config(path);
|
||||
}
|
||||
return Err(ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Specified config file not found: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
|
||||
// Check for SUPERFREQ_CONFIG environment variable
|
||||
if let Ok(env_path) = std::env::var("SUPERFREQ_CONFIG") {
|
||||
let env_path = Path::new(&env_path);
|
||||
if env_path.exists() {
|
||||
println!(
|
||||
"Loading config from SUPERFREQ_CONFIG: {}",
|
||||
env_path.display()
|
||||
);
|
||||
return load_and_parse_config(env_path);
|
||||
}
|
||||
eprintln!(
|
||||
"Warning: Config file specified by SUPERFREQ_CONFIG not found: {}",
|
||||
env_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// System-wide paths
|
||||
let config_paths = vec![
|
||||
PathBuf::from("/etc/xdg/superfreq/config.toml"),
|
||||
PathBuf::from("/etc/superfreq.toml"),
|
||||
];
|
||||
|
||||
for path in config_paths {
|
||||
if path.exists() {
|
||||
println!("Loading config from: {}", path.display());
|
||||
match load_and_parse_config(&path) {
|
||||
Ok(config) => return Ok(config),
|
||||
Err(e) => {
|
||||
eprintln!("Error with config file {}: {}", path.display(), e);
|
||||
// Continue trying other files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No configuration file found or all failed to parse. Using default configuration.");
|
||||
// Construct default AppConfig by converting default AppConfigToml
|
||||
let default_toml_config = AppConfigToml::default();
|
||||
Ok(AppConfig {
|
||||
charger: ProfileConfig::from(default_toml_config.charger),
|
||||
battery: ProfileConfig::from(default_toml_config.battery),
|
||||
ignored_power_supplies: default_toml_config.ignored_power_supplies,
|
||||
daemon: DaemonConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load and parse a configuration file
|
||||
fn load_and_parse_config(path: &Path) -> Result<AppConfig, ConfigError> {
|
||||
let contents = fs::read_to_string(path).map_err(ConfigError::Io)?;
|
||||
|
||||
let toml_app_config = toml::from_str::<AppConfigToml>(&contents).map_err(ConfigError::Toml)?;
|
||||
|
||||
// Handle inheritance of values from global to profile configs
|
||||
let mut charger_profile = toml_app_config.charger.clone();
|
||||
let mut battery_profile = toml_app_config.battery.clone();
|
||||
|
||||
// Clone global battery_charge_thresholds once if it exists
|
||||
if let Some(global_thresholds) = toml_app_config.battery_charge_thresholds {
|
||||
// Apply to charger profile if not already set
|
||||
if charger_profile.battery_charge_thresholds.is_none() {
|
||||
charger_profile.battery_charge_thresholds = Some(global_thresholds.clone());
|
||||
}
|
||||
|
||||
// Apply to battery profile if not already set
|
||||
if battery_profile.battery_charge_thresholds.is_none() {
|
||||
battery_profile.battery_charge_thresholds = Some(global_thresholds);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert AppConfigToml to AppConfig
|
||||
Ok(AppConfig {
|
||||
charger: ProfileConfig::from(charger_profile),
|
||||
battery: ProfileConfig::from(battery_profile),
|
||||
ignored_power_supplies: toml_app_config.ignored_power_supplies,
|
||||
daemon: DaemonConfig {
|
||||
poll_interval_sec: toml_app_config.daemon.poll_interval_sec,
|
||||
adaptive_interval: toml_app_config.daemon.adaptive_interval,
|
||||
min_poll_interval_sec: toml_app_config.daemon.min_poll_interval_sec,
|
||||
max_poll_interval_sec: toml_app_config.daemon.max_poll_interval_sec,
|
||||
throttle_on_battery: toml_app_config.daemon.throttle_on_battery,
|
||||
log_level: toml_app_config.daemon.log_level,
|
||||
stats_file_path: toml_app_config.daemon.stats_file_path,
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
pub mod load;
|
||||
pub mod types;
|
||||
|
||||
pub use load::*;
|
||||
pub use types::*;
|
|
@ -1,312 +0,0 @@
|
|||
// Configuration types and structures for superfreq
|
||||
use crate::core::TurboSetting;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
/// Defines constant-returning functions used for default values.
|
||||
/// This hopefully reduces repetition since we have way too many default functions
|
||||
/// that just return constants.
|
||||
macro_rules! default_const {
|
||||
($name:ident, $type:ty, $value:expr) => {
|
||||
const fn $name() -> $type {
|
||||
$value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatteryChargeThresholds {
|
||||
pub start: u8,
|
||||
pub stop: u8,
|
||||
}
|
||||
|
||||
impl BatteryChargeThresholds {
|
||||
pub fn new(start: u8, stop: u8) -> Result<Self, ConfigError> {
|
||||
if stop == 0 {
|
||||
return Err(ConfigError::Validation(
|
||||
"Stop threshold must be greater than 0%".to_string(),
|
||||
));
|
||||
}
|
||||
if start >= stop {
|
||||
return Err(ConfigError::Validation(format!(
|
||||
"Start threshold ({start}) must be less than stop threshold ({stop})"
|
||||
)));
|
||||
}
|
||||
if stop > 100 {
|
||||
return Err(ConfigError::Validation(format!(
|
||||
"Stop threshold ({stop}) cannot exceed 100%"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self { start, stop })
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<(u8, u8)> for BatteryChargeThresholds {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(values: (u8, u8)) -> Result<Self, Self::Error> {
|
||||
let (start, stop) = values;
|
||||
Self::new(start, stop)
|
||||
}
|
||||
}
|
||||
|
||||
// Structs for configuration using serde::Deserialize
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ProfileConfig {
|
||||
pub governor: Option<String>,
|
||||
pub turbo: Option<TurboSetting>,
|
||||
pub epp: Option<String>, // Energy Performance Preference (EPP)
|
||||
pub epb: Option<String>, // Energy Performance Bias (EPB) - usually an integer, but string for flexibility from sysfs
|
||||
pub min_freq_mhz: Option<u32>,
|
||||
pub max_freq_mhz: Option<u32>,
|
||||
pub platform_profile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub turbo_auto_settings: TurboAutoSettings,
|
||||
#[serde(default)]
|
||||
pub enable_auto_turbo: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
}
|
||||
|
||||
impl Default for ProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
governor: Some("schedutil".to_string()), // common sensible default (?)
|
||||
turbo: Some(TurboSetting::Auto),
|
||||
epp: None, // defaults depend on governor and system
|
||||
epb: None, // defaults depend on governor and system
|
||||
min_freq_mhz: None, // no override
|
||||
max_freq_mhz: None, // no override
|
||||
platform_profile: None, // no override
|
||||
turbo_auto_settings: TurboAutoSettings::default(),
|
||||
enable_auto_turbo: default_enable_auto_turbo(),
|
||||
battery_charge_thresholds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub charger: ProfileConfig,
|
||||
#[serde(default)]
|
||||
pub battery: ProfileConfig,
|
||||
pub ignored_power_supplies: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfig,
|
||||
}
|
||||
|
||||
// Error type for config loading
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parsing error: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
#[error("Configuration validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
// Intermediate structs for TOML parsing
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ProfileConfigToml {
|
||||
pub governor: Option<String>,
|
||||
pub turbo: Option<String>, // "always", "auto", "never"
|
||||
pub epp: Option<String>,
|
||||
pub epb: Option<String>,
|
||||
pub min_freq_mhz: Option<u32>,
|
||||
pub max_freq_mhz: Option<u32>,
|
||||
pub platform_profile: Option<String>,
|
||||
pub turbo_auto_settings: Option<TurboAutoSettings>,
|
||||
#[serde(default = "default_enable_auto_turbo")]
|
||||
pub enable_auto_turbo: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct AppConfigToml {
|
||||
#[serde(default)]
|
||||
pub charger: ProfileConfigToml,
|
||||
#[serde(default)]
|
||||
pub battery: ProfileConfigToml,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_charge_thresholds: Option<BatteryChargeThresholds>,
|
||||
pub ignored_power_supplies: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfigToml,
|
||||
}
|
||||
|
||||
impl Default for ProfileConfigToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
governor: Some("schedutil".to_string()),
|
||||
turbo: Some("auto".to_string()),
|
||||
epp: None,
|
||||
epb: None,
|
||||
min_freq_mhz: None,
|
||||
max_freq_mhz: None,
|
||||
platform_profile: None,
|
||||
turbo_auto_settings: None,
|
||||
enable_auto_turbo: default_enable_auto_turbo(),
|
||||
battery_charge_thresholds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct TurboAutoSettings {
|
||||
#[serde(default = "default_load_threshold_high")]
|
||||
pub load_threshold_high: f32,
|
||||
#[serde(default = "default_load_threshold_low")]
|
||||
pub load_threshold_low: f32,
|
||||
#[serde(default = "default_temp_threshold_high")]
|
||||
pub temp_threshold_high: f32,
|
||||
/// Initial turbo boost state when no previous state exists.
|
||||
/// Set to `true` to start with turbo enabled, `false` to start with turbo disabled.
|
||||
/// This is only used at first launch or after a reset.
|
||||
#[serde(default = "default_initial_turbo_state")]
|
||||
pub initial_turbo_state: bool,
|
||||
}
|
||||
|
||||
// Default thresholds for Auto turbo mode
|
||||
pub const DEFAULT_LOAD_THRESHOLD_HIGH: f32 = 70.0; // enable turbo if load is above this
|
||||
pub const DEFAULT_LOAD_THRESHOLD_LOW: f32 = 30.0; // disable turbo if load is below this
|
||||
pub const DEFAULT_TEMP_THRESHOLD_HIGH: f32 = 75.0; // disable turbo if temperature is above this
|
||||
pub const DEFAULT_INITIAL_TURBO_STATE: bool = false; // by default, start with turbo disabled
|
||||
|
||||
default_const!(
|
||||
default_load_threshold_high,
|
||||
f32,
|
||||
DEFAULT_LOAD_THRESHOLD_HIGH
|
||||
);
|
||||
default_const!(default_load_threshold_low, f32, DEFAULT_LOAD_THRESHOLD_LOW);
|
||||
default_const!(
|
||||
default_temp_threshold_high,
|
||||
f32,
|
||||
DEFAULT_TEMP_THRESHOLD_HIGH
|
||||
);
|
||||
default_const!(
|
||||
default_initial_turbo_state,
|
||||
bool,
|
||||
DEFAULT_INITIAL_TURBO_STATE
|
||||
);
|
||||
|
||||
impl Default for TurboAutoSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
load_threshold_high: DEFAULT_LOAD_THRESHOLD_HIGH,
|
||||
load_threshold_low: DEFAULT_LOAD_THRESHOLD_LOW,
|
||||
temp_threshold_high: DEFAULT_TEMP_THRESHOLD_HIGH,
|
||||
initial_turbo_state: DEFAULT_INITIAL_TURBO_STATE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileConfigToml> for ProfileConfig {
|
||||
fn from(toml_config: ProfileConfigToml) -> Self {
|
||||
Self {
|
||||
governor: toml_config.governor,
|
||||
turbo: toml_config
|
||||
.turbo
|
||||
.and_then(|s| match s.to_lowercase().as_str() {
|
||||
"always" => Some(TurboSetting::Always),
|
||||
"auto" => Some(TurboSetting::Auto),
|
||||
"never" => Some(TurboSetting::Never),
|
||||
_ => None,
|
||||
}),
|
||||
epp: toml_config.epp,
|
||||
epb: toml_config.epb,
|
||||
min_freq_mhz: toml_config.min_freq_mhz,
|
||||
max_freq_mhz: toml_config.max_freq_mhz,
|
||||
platform_profile: toml_config.platform_profile,
|
||||
turbo_auto_settings: toml_config.turbo_auto_settings.unwrap_or_default(),
|
||||
enable_auto_turbo: toml_config.enable_auto_turbo,
|
||||
battery_charge_thresholds: toml_config.battery_charge_thresholds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DaemonConfig {
|
||||
#[serde(default = "default_poll_interval_sec")]
|
||||
pub poll_interval_sec: u64,
|
||||
#[serde(default = "default_adaptive_interval")]
|
||||
pub adaptive_interval: bool,
|
||||
#[serde(default = "default_min_poll_interval_sec")]
|
||||
pub min_poll_interval_sec: u64,
|
||||
#[serde(default = "default_max_poll_interval_sec")]
|
||||
pub max_poll_interval_sec: u64,
|
||||
#[serde(default = "default_throttle_on_battery")]
|
||||
pub throttle_on_battery: bool,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: LogLevel,
|
||||
#[serde(default = "default_stats_file_path")]
|
||||
pub stats_file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_sec: default_poll_interval_sec(),
|
||||
adaptive_interval: default_adaptive_interval(),
|
||||
min_poll_interval_sec: default_min_poll_interval_sec(),
|
||||
max_poll_interval_sec: default_max_poll_interval_sec(),
|
||||
throttle_on_battery: default_throttle_on_battery(),
|
||||
log_level: default_log_level(),
|
||||
stats_file_path: default_stats_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_const!(default_poll_interval_sec, u64, 5);
|
||||
default_const!(default_adaptive_interval, bool, false);
|
||||
default_const!(default_min_poll_interval_sec, u64, 1);
|
||||
default_const!(default_max_poll_interval_sec, u64, 30);
|
||||
default_const!(default_throttle_on_battery, bool, true);
|
||||
default_const!(default_log_level, LogLevel, LogLevel::Info);
|
||||
default_const!(default_stats_file_path, Option<String>, None);
|
||||
default_const!(default_enable_auto_turbo, bool, true);
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DaemonConfigToml {
|
||||
#[serde(default = "default_poll_interval_sec")]
|
||||
pub poll_interval_sec: u64,
|
||||
#[serde(default = "default_adaptive_interval")]
|
||||
pub adaptive_interval: bool,
|
||||
#[serde(default = "default_min_poll_interval_sec")]
|
||||
pub min_poll_interval_sec: u64,
|
||||
#[serde(default = "default_max_poll_interval_sec")]
|
||||
pub max_poll_interval_sec: u64,
|
||||
#[serde(default = "default_throttle_on_battery")]
|
||||
pub throttle_on_battery: bool,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: LogLevel,
|
||||
#[serde(default = "default_stats_file_path")]
|
||||
pub stats_file_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfigToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_sec: default_poll_interval_sec(),
|
||||
adaptive_interval: default_adaptive_interval(),
|
||||
min_poll_interval_sec: default_min_poll_interval_sec(),
|
||||
max_poll_interval_sec: default_max_poll_interval_sec(),
|
||||
throttle_on_battery: default_throttle_on_battery(),
|
||||
log_level: default_log_level(),
|
||||
stats_file_path: default_stats_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
89
src/core.rs
89
src/core.rs
|
@ -1,89 +0,0 @@
|
|||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
|
||||
pub enum TurboSetting {
|
||||
Always, // turbo is forced on (if possible)
|
||||
Auto, // system or driver controls turbo
|
||||
Never, // turbo is forced off
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum GovernorOverrideMode {
|
||||
Performance,
|
||||
Powersave,
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl fmt::Display for GovernorOverrideMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Performance => write!(f, "performance"),
|
||||
Self::Powersave => write!(f, "powersave"),
|
||||
Self::Reset => write!(f, "reset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SystemInfo {
|
||||
// Overall system details
|
||||
pub cpu_model: String,
|
||||
pub architecture: String,
|
||||
pub linux_distribution: String,
|
||||
}
|
||||
|
||||
pub struct CpuCoreInfo {
|
||||
// Per-core data
|
||||
pub core_id: u32,
|
||||
pub current_frequency_mhz: Option<u32>,
|
||||
pub min_frequency_mhz: Option<u32>,
|
||||
pub max_frequency_mhz: Option<u32>,
|
||||
pub usage_percent: Option<f32>,
|
||||
pub temperature_celsius: Option<f32>,
|
||||
}
|
||||
|
||||
pub struct CpuGlobalInfo {
|
||||
// System-wide CPU settings
|
||||
pub current_governor: Option<String>,
|
||||
pub available_governors: Vec<String>,
|
||||
pub turbo_status: Option<bool>, // true for enabled, false for disabled
|
||||
pub epp: Option<String>, // Energy Performance Preference
|
||||
pub epb: Option<String>, // Energy Performance Bias
|
||||
pub platform_profile: Option<String>,
|
||||
pub average_temperature_celsius: Option<f32>, // Average temperature across all cores
|
||||
}
|
||||
|
||||
pub struct BatteryInfo {
|
||||
// Battery status (AC connected, charging state, capacity, power rate, charge start/stop thresholds if available).
|
||||
pub name: String,
|
||||
pub ac_connected: bool,
|
||||
pub charging_state: Option<String>, // e.g., "Charging", "Discharging", "Full"
|
||||
pub capacity_percent: Option<u8>,
|
||||
pub power_rate_watts: Option<f32>, // positive for charging, negative for discharging
|
||||
pub charge_start_threshold: Option<u8>,
|
||||
pub charge_stop_threshold: Option<u8>,
|
||||
}
|
||||
|
||||
pub struct SystemLoad {
|
||||
// System load averages.
|
||||
pub load_avg_1min: f32,
|
||||
pub load_avg_5min: f32,
|
||||
pub load_avg_15min: f32,
|
||||
}
|
||||
|
||||
pub struct SystemReport {
|
||||
// Now combine all the above for a snapshot of the system state.
|
||||
pub system_info: SystemInfo,
|
||||
pub cpu_cores: Vec<CpuCoreInfo>,
|
||||
pub cpu_global: CpuGlobalInfo,
|
||||
pub batteries: Vec<BatteryInfo>,
|
||||
pub system_load: SystemLoad,
|
||||
pub timestamp: std::time::SystemTime, // so we know when the report was generated
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OperationalMode {
|
||||
Powersave,
|
||||
Performance,
|
||||
}
|
1188
src/cpu.rs
1188
src/cpu.rs
File diff suppressed because it is too large
Load diff
933
src/daemon.rs
933
src/daemon.rs
File diff suppressed because it is too large
Load diff
133
src/engine.rs
133
src/engine.rs
|
@ -1,9 +1,7 @@
|
|||
use crate::battery;
|
||||
use crate::config::{AppConfig, ProfileConfig, TurboAutoSettings};
|
||||
use crate::core::{OperationalMode, SystemReport, TurboSetting};
|
||||
use crate::core::{OperationalMode, SystemReport};
|
||||
use crate::cpu::{self};
|
||||
use crate::util::error::{ControlError, EngineError};
|
||||
use log::{debug, info, warn};
|
||||
use crate::power_supply;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
@ -120,30 +118,14 @@ impl TurboHysteresis {
|
|||
/// 1. Try to apply a feature setting
|
||||
/// 2. If not supported, log a warning and continue
|
||||
/// 3. If other error, propagate the error
|
||||
fn try_apply_feature<F, T>(
|
||||
fn try_apply_feature<F: FnOnce() -> anyhow::Result<()>, T>(
|
||||
feature_name: &str,
|
||||
value_description: &str,
|
||||
apply_fn: F,
|
||||
) -> Result<(), EngineError>
|
||||
where
|
||||
F: FnOnce() -> Result<T, ControlError>,
|
||||
{
|
||||
info!("Setting {feature_name} to '{value_description}'");
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Setting {feature_name} to '{value_description}'");
|
||||
|
||||
match apply_fn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
if matches!(e, ControlError::NotSupported(_)) {
|
||||
warn!(
|
||||
"{feature_name} setting is not supported on this system. Skipping {feature_name} configuration."
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
// Propagate all other errors, including InvalidValueError
|
||||
Err(EngineError::ControlError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_fn()
|
||||
}
|
||||
|
||||
/// Determines the appropriate CPU profile based on power status or forced mode,
|
||||
|
@ -152,19 +134,19 @@ pub fn determine_and_apply_settings(
|
|||
report: &SystemReport,
|
||||
config: &AppConfig,
|
||||
force_mode: Option<OperationalMode>,
|
||||
) -> Result<(), EngineError> {
|
||||
// First, check if there's a governor override set
|
||||
if let Some(override_governor) = cpu::get_governor_override() {
|
||||
info!(
|
||||
"Governor override is active: '{}'. Setting governor.",
|
||||
override_governor.trim()
|
||||
);
|
||||
) -> anyhow::Result<()> {
|
||||
// // First, check if there's a governor override set
|
||||
// if let Some(override_governor) = cpu::get_governor_override() {
|
||||
// log::info!(
|
||||
// "Governor override is active: '{}'. Setting governor.",
|
||||
// override_governor.trim()
|
||||
// );
|
||||
|
||||
// Apply the override governor setting
|
||||
try_apply_feature("override governor", override_governor.trim(), || {
|
||||
cpu::set_governor(override_governor.trim(), None)
|
||||
})?;
|
||||
}
|
||||
// // Apply the override governor setting
|
||||
// try_apply_feature("override governor", override_governor.trim(), || {
|
||||
// cpu::set_governor(override_governor.trim(), None)
|
||||
// })?;
|
||||
// }
|
||||
|
||||
// Determine AC/Battery status once, early in the function
|
||||
// For desktops (no batteries), we should always use the AC power profile
|
||||
|
@ -182,52 +164,46 @@ pub fn determine_and_apply_settings(
|
|||
if let Some(mode) = force_mode {
|
||||
match mode {
|
||||
OperationalMode::Powersave => {
|
||||
info!("Forced Powersave mode selected. Applying 'battery' profile.");
|
||||
log::info!("Forced Powersave mode selected. Applying 'battery' profile.");
|
||||
selected_profile_config = &config.battery;
|
||||
}
|
||||
OperationalMode::Performance => {
|
||||
info!("Forced Performance mode selected. Applying 'charger' profile.");
|
||||
log::info!("Forced Performance mode selected. Applying 'charger' profile.");
|
||||
selected_profile_config = &config.charger;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use the previously computed on_ac_power value
|
||||
if on_ac_power {
|
||||
info!("On AC power, selecting Charger profile.");
|
||||
log::info!("On AC power, selecting Charger profile.");
|
||||
selected_profile_config = &config.charger;
|
||||
} else {
|
||||
info!("On Battery power, selecting Battery profile.");
|
||||
log::info!("On Battery power, selecting Battery profile.");
|
||||
selected_profile_config = &config.battery;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply settings from selected_profile_config
|
||||
if let Some(governor) = &selected_profile_config.governor {
|
||||
info!("Setting governor to '{governor}'");
|
||||
// Let set_governor handle the validation
|
||||
if let Err(e) = cpu::set_governor(governor, None) {
|
||||
// If the governor is not available, log a warning
|
||||
if matches!(e, ControlError::InvalidGovernor(_))
|
||||
|| matches!(e, ControlError::NotSupported(_))
|
||||
{
|
||||
warn!(
|
||||
"Configured governor '{governor}' is not available on this system. Skipping."
|
||||
);
|
||||
} else {
|
||||
return Err(e.into());
|
||||
log::info!("Setting governor to '{governor}'");
|
||||
for cpu in cpu::Cpu::all()? {
|
||||
// Let set_governor handle the validation
|
||||
if let Err(error) = cpu.set_governor(governor) {
|
||||
// If the governor is not available, log a warning
|
||||
log::warn!("{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(turbo_setting) = selected_profile_config.turbo {
|
||||
info!("Setting turbo to '{turbo_setting:?}'");
|
||||
log::info!("Setting turbo to '{turbo_setting:?}'");
|
||||
match turbo_setting {
|
||||
TurboSetting::Auto => {
|
||||
if selected_profile_config.enable_auto_turbo {
|
||||
debug!("Managing turbo in auto mode based on system conditions");
|
||||
log::debug!("Managing turbo in auto mode based on system conditions");
|
||||
manage_auto_turbo(report, selected_profile_config, on_ac_power)?;
|
||||
} else {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Superfreq's dynamic turbo management is disabled by configuration. Ensuring system uses its default behavior for automatic turbo control."
|
||||
);
|
||||
// Make sure the system is set to its default automatic turbo mode.
|
||||
|
@ -255,13 +231,13 @@ pub fn determine_and_apply_settings(
|
|||
|
||||
if let Some(min_freq) = selected_profile_config.min_freq_mhz {
|
||||
try_apply_feature("min frequency", &format!("{min_freq} MHz"), || {
|
||||
cpu::set_min_frequency(min_freq, None)
|
||||
cpu::set_frequency_minimum(min_freq, None)
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(max_freq) = selected_profile_config.max_freq_mhz {
|
||||
try_apply_feature("max frequency", &format!("{max_freq} MHz"), || {
|
||||
cpu::set_max_frequency(max_freq, None)
|
||||
cpu::set_frequency_maximum(max_freq, None)
|
||||
})?;
|
||||
}
|
||||
|
||||
|
@ -277,19 +253,19 @@ pub fn determine_and_apply_settings(
|
|||
let stop_threshold = thresholds.stop;
|
||||
|
||||
if start_threshold < stop_threshold && stop_threshold <= 100 {
|
||||
info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%");
|
||||
match battery::set_battery_charge_thresholds(start_threshold, stop_threshold) {
|
||||
Ok(()) => debug!("Battery charge thresholds set successfully"),
|
||||
Err(e) => warn!("Failed to set battery charge thresholds: {e}"),
|
||||
log::info!("Setting battery charge thresholds: {start_threshold}-{stop_threshold}%");
|
||||
match power_supply::set_battery_charge_thresholds(start_threshold, stop_threshold) {
|
||||
Ok(()) => log::debug!("Battery charge thresholds set successfully"),
|
||||
Err(e) => log::warn!("Failed to set battery charge thresholds: {e}"),
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
log::warn!(
|
||||
"Invalid battery threshold values: start={start_threshold}, stop={stop_threshold}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Profile settings applied successfully.");
|
||||
log::debug!("Profile settings applied successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -298,7 +274,7 @@ fn manage_auto_turbo(
|
|||
report: &SystemReport,
|
||||
config: &ProfileConfig,
|
||||
on_ac_power: bool,
|
||||
) -> Result<(), EngineError> {
|
||||
) -> anyhow::Result<()> {
|
||||
// Get the auto turbo settings from the config
|
||||
let turbo_settings = &config.turbo_auto_settings;
|
||||
|
||||
|
@ -346,27 +322,30 @@ fn manage_auto_turbo(
|
|||
let enable_turbo = match (cpu_temp, avg_cpu_usage, previous_turbo_enabled) {
|
||||
// If temperature is too high, disable turbo regardless of load
|
||||
(Some(temp), _, _) if temp >= turbo_settings.temp_threshold_high => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Disabled due to high temperature ({:.1}°C >= {:.1}°C)",
|
||||
temp, turbo_settings.temp_threshold_high
|
||||
temp,
|
||||
turbo_settings.temp_threshold_high
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
// If load is high enough, enable turbo (unless temp already caused it to disable)
|
||||
(_, Some(usage), _) if usage >= turbo_settings.load_threshold_high => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Enabled due to high CPU load ({:.1}% >= {:.1}%)",
|
||||
usage, turbo_settings.load_threshold_high
|
||||
usage,
|
||||
turbo_settings.load_threshold_high
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
// If load is low, disable turbo
|
||||
(_, Some(usage), _) if usage <= turbo_settings.load_threshold_low => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Disabled due to low CPU load ({:.1}% <= {:.1}%)",
|
||||
usage, turbo_settings.load_threshold_low
|
||||
usage,
|
||||
turbo_settings.load_threshold_low
|
||||
);
|
||||
false
|
||||
}
|
||||
|
@ -376,7 +355,7 @@ fn manage_auto_turbo(
|
|||
if usage > turbo_settings.load_threshold_low
|
||||
&& usage < turbo_settings.load_threshold_high =>
|
||||
{
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to intermediate load ({:.1}%)",
|
||||
if prev_state { "enabled" } else { "disabled" },
|
||||
usage
|
||||
|
@ -386,7 +365,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// When CPU load data is present but temperature is missing, use the same hysteresis logic
|
||||
(None, Some(usage), prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to missing temperature data (load: {:.1}%)",
|
||||
if prev_state { "enabled" } else { "disabled" },
|
||||
usage
|
||||
|
@ -396,7 +375,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// When all metrics are missing, maintain the previous state
|
||||
(None, None, prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to missing all CPU metrics",
|
||||
if prev_state { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -405,7 +384,7 @@ fn manage_auto_turbo(
|
|||
|
||||
// Any other cases with partial metrics, maintain previous state for stability
|
||||
(_, _, prev_state) => {
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Maintaining previous state ({}) due to incomplete CPU metrics",
|
||||
if prev_state { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -429,7 +408,7 @@ fn manage_auto_turbo(
|
|||
TurboSetting::Never
|
||||
};
|
||||
|
||||
info!(
|
||||
log::info!(
|
||||
"Auto Turbo: Applying turbo change from {} to {}",
|
||||
if previous_turbo_enabled {
|
||||
"enabled"
|
||||
|
@ -441,7 +420,7 @@ fn manage_auto_turbo(
|
|||
|
||||
match cpu::set_turbo(turbo_setting) {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Auto Turbo: Successfully set turbo to {}",
|
||||
if enable_turbo { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
@ -450,7 +429,7 @@ fn manage_auto_turbo(
|
|||
Err(e) => Err(EngineError::ControlError(e)),
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
log::debug!(
|
||||
"Auto Turbo: Maintaining turbo state ({}) - no change needed",
|
||||
if enable_turbo { "enabled" } else { "disabled" }
|
||||
);
|
||||
|
|
65
src/fs.rs
Normal file
65
src/fs.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use std::{error, fs, io, path::Path, str};
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
|
||||
path.exists()
|
||||
}
|
||||
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> anyhow::Result<Option<fs::ReadDir>> {
|
||||
let path = path.as_ref();
|
||||
|
||||
match fs::read_dir(path) {
|
||||
Ok(entries) => Ok(Some(entries)),
|
||||
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
|
||||
Err(error) => Err(error).context(format!(
|
||||
"failed to read directory '{path}'",
|
||||
path = path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(path: impl AsRef<Path>) -> anyhow::Result<Option<String>> {
|
||||
let path = path.as_ref();
|
||||
|
||||
match fs::read_to_string(path) {
|
||||
Ok(string) => Ok(Some(string.trim().to_owned())),
|
||||
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
|
||||
Err(error) => Err(error).context(format!("failed to read '{path}", path = path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_n<N: str::FromStr>(path: impl AsRef<Path>) -> anyhow::Result<Option<N>>
|
||||
where
|
||||
N::Err: error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
|
||||
match read(path)? {
|
||||
Some(content) => Ok(Some(content.trim().parse().with_context(|| {
|
||||
format!(
|
||||
"failed to parse contents of '{path}' as a unsigned number",
|
||||
path = path.display(),
|
||||
)
|
||||
})?)),
|
||||
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(path: impl AsRef<Path>, value: &str) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
fs::write(path, value).with_context(|| {
|
||||
format!(
|
||||
"failed to write '{value}' to '{path}'",
|
||||
path = path.display(),
|
||||
)
|
||||
})
|
||||
}
|
568
src/main.rs
568
src/main.rs
|
@ -1,476 +1,152 @@
|
|||
mod battery;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod core;
|
||||
mod cpu;
|
||||
mod power_supply;
|
||||
mod system;
|
||||
|
||||
mod fs;
|
||||
|
||||
mod config;
|
||||
// mod core;
|
||||
mod daemon;
|
||||
mod engine;
|
||||
mod monitor;
|
||||
mod util;
|
||||
// mod engine;
|
||||
// mod monitor;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::{GovernorOverrideMode, TurboSetting};
|
||||
use crate::util::error::{AppError, ControlError};
|
||||
use clap::{Parser, value_parser};
|
||||
use env_logger::Builder;
|
||||
use log::{debug, error, info};
|
||||
use std::error::Error;
|
||||
use std::sync::Once;
|
||||
use anyhow::Context;
|
||||
use clap::Parser as _;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::{io, process};
|
||||
use yansi::Paint as _;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
#[derive(clap::Parser, Debug)]
|
||||
#[clap(author, version, about)]
|
||||
struct Cli {
|
||||
#[clap(subcommand)]
|
||||
command: Option<Commands>,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
enum Commands {
|
||||
/// Display current system information
|
||||
Info,
|
||||
/// Run as a daemon in the background
|
||||
Daemon {
|
||||
#[clap(long)]
|
||||
verbose: bool,
|
||||
#[derive(clap::Parser, Debug)]
|
||||
#[clap(multicall = true)]
|
||||
enum Command {
|
||||
/// Watt daemon.
|
||||
Watt {
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
|
||||
/// The daemon config path.
|
||||
#[arg(long, env = "WATT_CONFIG")]
|
||||
config: PathBuf,
|
||||
},
|
||||
/// Set CPU governor
|
||||
SetGovernor {
|
||||
governor: String,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
|
||||
/// CPU metadata and modification utility.
|
||||
Cpu {
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
|
||||
#[clap(subcommand)]
|
||||
command: CpuCommand,
|
||||
},
|
||||
/// Force a specific governor mode persistently
|
||||
ForceGovernor {
|
||||
/// Mode to force: performance, powersave, or reset
|
||||
#[clap(value_enum)]
|
||||
mode: GovernorOverrideMode,
|
||||
},
|
||||
/// Set turbo boost behavior
|
||||
SetTurbo {
|
||||
#[clap(value_enum)]
|
||||
setting: TurboSetting,
|
||||
},
|
||||
/// Display comprehensive debug information
|
||||
Debug,
|
||||
/// Set Energy Performance Preference (EPP)
|
||||
SetEpp {
|
||||
epp: String,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set Energy Performance Bias (EPB)
|
||||
SetEpb {
|
||||
epb: String, // Typically 0-15
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set minimum CPU frequency
|
||||
SetMinFreq {
|
||||
freq_mhz: u32,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set maximum CPU frequency
|
||||
SetMaxFreq {
|
||||
freq_mhz: u32,
|
||||
#[clap(long)]
|
||||
core_id: Option<u32>,
|
||||
},
|
||||
/// Set ACPI platform profile
|
||||
SetPlatformProfile { profile: String },
|
||||
/// Set battery charge thresholds to extend battery lifespan
|
||||
SetBatteryThresholds {
|
||||
/// Percentage at which charging starts (when below this value)
|
||||
#[clap(value_parser = value_parser!(u8).range(0..=99))]
|
||||
start_threshold: u8,
|
||||
/// Percentage at which charging stops (when it reaches this value)
|
||||
#[clap(value_parser = value_parser!(u8).range(1..=100))]
|
||||
stop_threshold: u8,
|
||||
|
||||
/// Power supply metadata and modification utility.
|
||||
Power {
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
|
||||
#[clap(subcommand)]
|
||||
command: PowerCommand,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<(), AppError> {
|
||||
// Initialize logger once for the entire application
|
||||
init_logger();
|
||||
#[derive(clap::Parser, Debug)]
|
||||
enum CpuCommand {
|
||||
/// Modify CPU attributes.
|
||||
Set(config::CpuDelta),
|
||||
}
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
enum PowerCommand {
|
||||
/// Modify power supply attributes.
|
||||
Set(config::PowerDelta),
|
||||
}
|
||||
|
||||
fn real_main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Load configuration first, as it might be needed by the monitor module
|
||||
// E.g., for ignored power supplies
|
||||
let config = match config::load_config() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
error!("Error loading configuration: {e}. Using default values.");
|
||||
// Proceed with default config if loading fails
|
||||
AppConfig::default()
|
||||
yansi::whenever(yansi::Condition::TTY_AND_COLOR);
|
||||
|
||||
let (Command::Watt { verbosity, .. }
|
||||
| Command::Cpu { verbosity, .. }
|
||||
| Command::Power { verbosity, .. }) = cli.command;
|
||||
|
||||
env_logger::Builder::new()
|
||||
.filter_level(verbosity.log_level_filter())
|
||||
.format_timestamp(None)
|
||||
.format_module_path(false)
|
||||
.init();
|
||||
|
||||
match cli.command {
|
||||
Command::Watt { config, .. } => {
|
||||
let config =
|
||||
config::DaemonConfig::load_from(&config).context("failed to load daemon config")?;
|
||||
|
||||
daemon::run(config)
|
||||
}
|
||||
|
||||
Command::Cpu {
|
||||
command: CpuCommand::Set(delta),
|
||||
..
|
||||
} => delta.apply(),
|
||||
|
||||
Command::Power {
|
||||
command: PowerCommand::Set(delta),
|
||||
..
|
||||
} => delta.apply(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let Err(error) = real_main() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let command_result: Result<(), AppError> = match cli.command {
|
||||
// TODO: This will be moved to a different module in the future.
|
||||
Some(Commands::Info) => match monitor::collect_system_report(&config) {
|
||||
Ok(report) => {
|
||||
// Format section headers with proper centering
|
||||
let format_section = |title: &str| {
|
||||
let title_len = title.len();
|
||||
let total_width = title_len + 8; // 8 is for padding (4 on each side)
|
||||
let separator = "═".repeat(total_width);
|
||||
let mut err = io::stderr();
|
||||
|
||||
println!("\n╔{separator}╗");
|
||||
let mut message = String::new();
|
||||
let mut chain = error.chain().rev().peekable();
|
||||
|
||||
// Calculate centering
|
||||
println!("║ {title} ║");
|
||||
|
||||
println!("╚{separator}╝");
|
||||
};
|
||||
|
||||
format_section("System Information");
|
||||
println!("CPU Model: {}", report.system_info.cpu_model);
|
||||
println!("Architecture: {}", report.system_info.architecture);
|
||||
println!(
|
||||
"Linux Distribution: {}",
|
||||
report.system_info.linux_distribution
|
||||
);
|
||||
|
||||
// Format timestamp in a readable way
|
||||
println!("Current Time: {}", jiff::Timestamp::now());
|
||||
|
||||
format_section("CPU Global Info");
|
||||
println!(
|
||||
"Current Governor: {}",
|
||||
report
|
||||
.cpu_global
|
||||
.current_governor
|
||||
.as_deref()
|
||||
.unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"Available Governors: {}", // 21 length baseline
|
||||
report.cpu_global.available_governors.join(", ")
|
||||
);
|
||||
println!(
|
||||
"Turbo Status: {}",
|
||||
match report.cpu_global.turbo_status {
|
||||
Some(true) => "Enabled",
|
||||
Some(false) => "Disabled",
|
||||
None => "Unknown",
|
||||
}
|
||||
);
|
||||
|
||||
println!(
|
||||
"EPP: {}",
|
||||
report.cpu_global.epp.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"EPB: {}",
|
||||
report.cpu_global.epb.as_deref().unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"Platform Profile: {}",
|
||||
report
|
||||
.cpu_global
|
||||
.platform_profile
|
||||
.as_deref()
|
||||
.unwrap_or("N/A")
|
||||
);
|
||||
println!(
|
||||
"CPU Temperature: {}",
|
||||
report.cpu_global.average_temperature_celsius.map_or_else(
|
||||
|| "N/A (No sensor detected)".to_string(),
|
||||
|t| format!("{t:.1}°C")
|
||||
)
|
||||
);
|
||||
|
||||
format_section("CPU Core Info");
|
||||
|
||||
// Get max core ID length for padding
|
||||
let max_core_id_len = report
|
||||
.cpu_cores
|
||||
.last()
|
||||
.map_or(1, |core| core.core_id.to_string().len());
|
||||
|
||||
// Table headers
|
||||
println!(
|
||||
" {:>width$} │ {:^10} │ {:^10} │ {:^10} │ {:^7} │ {:^9}",
|
||||
"Core",
|
||||
"Current",
|
||||
"Min",
|
||||
"Max",
|
||||
"Usage",
|
||||
"Temp",
|
||||
width = max_core_id_len + 4
|
||||
);
|
||||
println!(
|
||||
" {:─>width$}──┼─{:─^10}─┼─{:─^10}─┼─{:─^10}─┼─{:─^7}─┼─{:─^9}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
width = max_core_id_len + 4
|
||||
);
|
||||
|
||||
for core_info in &report.cpu_cores {
|
||||
// Format frequencies: if current > max, show in a special way
|
||||
let current_freq = match core_info.current_frequency_mhz {
|
||||
Some(freq) => {
|
||||
let max_freq = core_info.max_frequency_mhz.unwrap_or(0);
|
||||
if freq > max_freq && max_freq > 0 {
|
||||
// Special format for boosted frequencies
|
||||
format!("{freq}*")
|
||||
} else {
|
||||
format!("{freq}")
|
||||
}
|
||||
}
|
||||
None => "N/A".to_string(),
|
||||
};
|
||||
|
||||
// CPU core display
|
||||
println!(
|
||||
" Core {:<width$} │ {:>10} │ {:>10} │ {:>10} │ {:>7} │ {:>9}",
|
||||
core_info.core_id,
|
||||
format!("{} MHz", current_freq),
|
||||
format!(
|
||||
"{} MHz",
|
||||
core_info
|
||||
.min_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
),
|
||||
format!(
|
||||
"{} MHz",
|
||||
core_info
|
||||
.max_frequency_mhz
|
||||
.map_or_else(|| "N/A".to_string(), |f| f.to_string())
|
||||
),
|
||||
format!(
|
||||
"{}%",
|
||||
core_info
|
||||
.usage_percent
|
||||
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
|
||||
),
|
||||
format!(
|
||||
"{}°C",
|
||||
core_info
|
||||
.temperature_celsius
|
||||
.map_or_else(|| "N/A".to_string(), |f| format!("{f:.1}"))
|
||||
),
|
||||
width = max_core_id_len
|
||||
);
|
||||
}
|
||||
|
||||
// Only display battery info for systems that have real batteries
|
||||
// Skip this section entirely on desktop systems
|
||||
if !report.batteries.is_empty() {
|
||||
let has_real_batteries = report.batteries.iter().any(|b| {
|
||||
// Check if any battery has actual battery data
|
||||
// (as opposed to peripherals like wireless mice)
|
||||
b.capacity_percent.is_some() || b.power_rate_watts.is_some()
|
||||
});
|
||||
|
||||
if has_real_batteries {
|
||||
format_section("Battery Info");
|
||||
for battery_info in &report.batteries {
|
||||
// Check if this appears to be a real system battery
|
||||
if battery_info.capacity_percent.is_some()
|
||||
|| battery_info.power_rate_watts.is_some()
|
||||
{
|
||||
let power_status = if battery_info.ac_connected {
|
||||
"Connected to AC"
|
||||
} else {
|
||||
"Running on Battery"
|
||||
};
|
||||
|
||||
println!("Battery {}:", battery_info.name);
|
||||
println!(" Power Status: {power_status}");
|
||||
println!(
|
||||
" State: {}",
|
||||
battery_info.charging_state.as_deref().unwrap_or("Unknown")
|
||||
);
|
||||
|
||||
if let Some(capacity) = battery_info.capacity_percent {
|
||||
println!(" Capacity: {capacity}%");
|
||||
}
|
||||
|
||||
if let Some(power) = battery_info.power_rate_watts {
|
||||
let direction = if power >= 0.0 {
|
||||
"charging"
|
||||
} else {
|
||||
"discharging"
|
||||
};
|
||||
println!(
|
||||
" Power Rate: {:.2} W ({})",
|
||||
power.abs(),
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
// Display charge thresholds if available
|
||||
if battery_info.charge_start_threshold.is_some()
|
||||
|| battery_info.charge_stop_threshold.is_some()
|
||||
{
|
||||
println!(
|
||||
" Charge Thresholds: {}-{}",
|
||||
battery_info
|
||||
.charge_start_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string()),
|
||||
battery_info
|
||||
.charge_stop_threshold
|
||||
.map_or_else(|| "N/A".to_string(), |t| t.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format_section("System Load");
|
||||
println!(
|
||||
"Load Average (1m): {:.2}",
|
||||
report.system_load.load_avg_1min
|
||||
);
|
||||
println!(
|
||||
"Load Average (5m): {:.2}",
|
||||
report.system_load.load_avg_5min
|
||||
);
|
||||
println!(
|
||||
"Load Average (15m): {:.2}",
|
||||
report.system_load.load_avg_15min
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(AppError::Monitor(e)),
|
||||
},
|
||||
Some(Commands::SetGovernor { governor, core_id }) => {
|
||||
cpu::set_governor(&governor, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::ForceGovernor { mode }) => {
|
||||
cpu::force_governor(mode).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetTurbo { setting }) => cpu::set_turbo(setting).map_err(AppError::Control),
|
||||
Some(Commands::SetEpp { epp, core_id }) => {
|
||||
cpu::set_epp(&epp, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetEpb { epb, core_id }) => {
|
||||
cpu::set_epb(&epb, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetMinFreq { freq_mhz, core_id }) => {
|
||||
// Basic validation for reasonable CPU frequency values
|
||||
validate_freq(freq_mhz, "Minimum")?;
|
||||
cpu::set_min_frequency(freq_mhz, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetMaxFreq { freq_mhz, core_id }) => {
|
||||
// Basic validation for reasonable CPU frequency values
|
||||
validate_freq(freq_mhz, "Maximum")?;
|
||||
cpu::set_max_frequency(freq_mhz, core_id).map_err(AppError::Control)
|
||||
}
|
||||
Some(Commands::SetPlatformProfile { profile }) => {
|
||||
// Get available platform profiles and validate early if possible
|
||||
match cpu::get_platform_profiles() {
|
||||
Ok(available_profiles) => {
|
||||
if available_profiles.contains(&profile) {
|
||||
info!("Setting platform profile to '{profile}'");
|
||||
cpu::set_platform_profile(&profile).map_err(AppError::Control)
|
||||
} else {
|
||||
error!(
|
||||
"Invalid platform profile: '{}'. Available profiles: {}",
|
||||
profile,
|
||||
available_profiles.join(", ")
|
||||
);
|
||||
Err(AppError::Generic(format!(
|
||||
"Invalid platform profile: '{}'. Available profiles: {}",
|
||||
profile,
|
||||
available_profiles.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
// If we can't get profiles (e.g., feature not supported), pass through to the function
|
||||
cpu::set_platform_profile(&profile).map_err(AppError::Control)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Commands::SetBatteryThresholds {
|
||||
start_threshold,
|
||||
stop_threshold,
|
||||
}) => {
|
||||
// We only need to check if start < stop since the range validation is handled by Clap
|
||||
if start_threshold >= stop_threshold {
|
||||
error!(
|
||||
"Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})"
|
||||
);
|
||||
Err(AppError::Generic(format!(
|
||||
"Start threshold ({start_threshold}) must be less than stop threshold ({stop_threshold})"
|
||||
)))
|
||||
while let Some(error) = chain.next() {
|
||||
let _ = write!(
|
||||
err,
|
||||
"{header} ",
|
||||
header = if chain.peek().is_none() {
|
||||
"error:"
|
||||
} else {
|
||||
info!(
|
||||
"Setting battery thresholds: start at {start_threshold}%, stop at {stop_threshold}%"
|
||||
);
|
||||
battery::set_battery_charge_thresholds(start_threshold, stop_threshold)
|
||||
.map_err(AppError::Control)
|
||||
"cause:"
|
||||
}
|
||||
}
|
||||
Some(Commands::Daemon { verbose }) => daemon::run_daemon(config, verbose),
|
||||
Some(Commands::Debug) => cli::debug::run_debug(&config),
|
||||
None => {
|
||||
info!("Welcome to superfreq! Use --help for commands.");
|
||||
debug!("Current effective configuration: {config:?}");
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
.red()
|
||||
.bold(),
|
||||
);
|
||||
|
||||
if let Err(e) = command_result {
|
||||
error!("Error executing command: {e}");
|
||||
if let Some(source) = e.source() {
|
||||
error!("Caused by: {source}");
|
||||
}
|
||||
String::clear(&mut message);
|
||||
let _ = write!(message, "{error}");
|
||||
|
||||
// Check for permission denied errors
|
||||
if let AppError::Control(control_error) = &e {
|
||||
if matches!(control_error, ControlError::PermissionDenied(_)) {
|
||||
error!(
|
||||
"Hint: This operation may require administrator privileges (e.g., run with sudo)."
|
||||
);
|
||||
let mut chars = message.char_indices();
|
||||
|
||||
let _ = match (chars.next(), chars.next()) {
|
||||
(Some((_, first)), Some((second_start, second))) if second.is_lowercase() => {
|
||||
writeln!(
|
||||
err,
|
||||
"{first_lowercase}{rest}",
|
||||
first_lowercase = first.to_lowercase(),
|
||||
rest = &message[second_start..],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(1);
|
||||
_ => {
|
||||
writeln!(err, "{message}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the logger for the entire application
|
||||
static LOGGER_INIT: Once = Once::new();
|
||||
fn init_logger() {
|
||||
LOGGER_INIT.call_once(|| {
|
||||
// Set default log level based on environment or default to Info
|
||||
let env_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
|
||||
|
||||
Builder::new()
|
||||
.parse_filters(&env_log)
|
||||
.format_timestamp(None)
|
||||
.format_module_path(false)
|
||||
.init();
|
||||
|
||||
debug!("Logger initialized with RUST_LOG={env_log}");
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate CPU frequency input values
|
||||
fn validate_freq(freq_mhz: u32, label: &str) -> Result<(), AppError> {
|
||||
if freq_mhz == 0 {
|
||||
error!("{label} frequency cannot be zero");
|
||||
Err(AppError::Generic(format!(
|
||||
"{label} frequency cannot be zero"
|
||||
)))
|
||||
} else if freq_mhz > 10000 {
|
||||
// Extremely high value unlikely to be valid
|
||||
error!("{label} frequency ({freq_mhz} MHz) is unreasonably high");
|
||||
Err(AppError::Generic(format!(
|
||||
"{label} frequency ({freq_mhz} MHz) is unreasonably high"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
|
|
811
src/monitor.rs
811
src/monitor.rs
|
@ -1,787 +1,24 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport};
|
||||
use crate::cpu::get_logical_core_count;
|
||||
use crate::util::error::SysMonitorError;
|
||||
use log::debug;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
thread,
|
||||
time::Duration,
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub type Result<T, E = SysMonitorError> = std::result::Result<T, E>;
|
||||
|
||||
// Read a sysfs file to a string, trimming whitespace
|
||||
fn read_sysfs_file_trimmed(path: impl AsRef<Path>) -> Result<String> {
|
||||
fs::read_to_string(path.as_ref())
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|e| {
|
||||
SysMonitorError::ReadError(format!("Path: {:?}, Error: {}", path.as_ref().display(), e))
|
||||
})
|
||||
}
|
||||
|
||||
// Read a sysfs file and parse it to a specific type
|
||||
fn read_sysfs_value<T: FromStr>(path: impl AsRef<Path>) -> Result<T> {
|
||||
let content = read_sysfs_file_trimmed(path.as_ref())?;
|
||||
content.parse::<T>().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!(
|
||||
"Could not parse '{}' from {:?}",
|
||||
content,
|
||||
path.as_ref().display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let linux_distribution = get_linux_distribution().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let architecture = std::env::consts::ARCH.to_string();
|
||||
|
||||
SystemInfo {
|
||||
cpu_model,
|
||||
architecture,
|
||||
linux_distribution,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CpuTimes {
|
||||
user: u64,
|
||||
nice: u64,
|
||||
system: u64,
|
||||
idle: u64,
|
||||
iowait: u64,
|
||||
irq: u64,
|
||||
softirq: u64,
|
||||
steal: u64,
|
||||
}
|
||||
|
||||
impl CpuTimes {
|
||||
const fn total_time(&self) -> u64 {
|
||||
self.user
|
||||
+ self.nice
|
||||
+ self.system
|
||||
+ self.idle
|
||||
+ self.iowait
|
||||
+ self.irq
|
||||
+ self.softirq
|
||||
+ self.steal
|
||||
}
|
||||
|
||||
const fn idle_time(&self) -> u64 {
|
||||
self.idle + self.iowait
|
||||
}
|
||||
}
|
||||
|
||||
fn read_all_cpu_times() -> Result<HashMap<u32, CpuTimes>> {
|
||||
let content = fs::read_to_string("/proc/stat").map_err(SysMonitorError::Io)?;
|
||||
let mut cpu_times_map = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("cpu") && line.chars().nth(3).is_some_and(|c| c.is_ascii_digit()) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 11 {
|
||||
return Err(SysMonitorError::ProcStatParseError(format!(
|
||||
"Line too short: {line}"
|
||||
)));
|
||||
}
|
||||
|
||||
let core_id_str = &parts[0][3..];
|
||||
let core_id = core_id_str.parse::<u32>().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse core_id: {core_id_str}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let times = CpuTimes {
|
||||
user: parts[1].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse user time: {}",
|
||||
parts[1]
|
||||
))
|
||||
})?,
|
||||
nice: parts[2].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse nice time: {}",
|
||||
parts[2]
|
||||
))
|
||||
})?,
|
||||
system: parts[3].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse system time: {}",
|
||||
parts[3]
|
||||
))
|
||||
})?,
|
||||
idle: parts[4].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse idle time: {}",
|
||||
parts[4]
|
||||
))
|
||||
})?,
|
||||
iowait: parts[5].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse iowait time: {}",
|
||||
parts[5]
|
||||
))
|
||||
})?,
|
||||
irq: parts[6].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse irq time: {}",
|
||||
parts[6]
|
||||
))
|
||||
})?,
|
||||
softirq: parts[7].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse softirq time: {}",
|
||||
parts[7]
|
||||
))
|
||||
})?,
|
||||
steal: parts[8].parse().map_err(|_| {
|
||||
SysMonitorError::ProcStatParseError(format!(
|
||||
"Failed to parse steal time: {}",
|
||||
parts[8]
|
||||
))
|
||||
})?,
|
||||
};
|
||||
cpu_times_map.insert(core_id, times);
|
||||
}
|
||||
}
|
||||
Ok(cpu_times_map)
|
||||
}
|
||||
|
||||
pub fn get_cpu_core_info(
|
||||
core_id: u32,
|
||||
prev_times: &CpuTimes,
|
||||
current_times: &CpuTimes,
|
||||
) -> Result<CpuCoreInfo> {
|
||||
let cpufreq_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{core_id}/cpufreq/"));
|
||||
|
||||
let current_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_cur_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
let min_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_min_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
let max_frequency_mhz = read_sysfs_value::<u32>(cpufreq_path.join("scaling_max_freq"))
|
||||
.map(|khz| khz / 1000)
|
||||
.ok();
|
||||
|
||||
// Temperature detection.
|
||||
// Should be generic enough to be able to support for multiple hardware sensors
|
||||
// with the possibility of extending later down the road.
|
||||
let mut temperature_celsius: Option<f32> = None;
|
||||
|
||||
// Search for temperature in hwmon devices
|
||||
if let Ok(hwmon_dir) = fs::read_dir("/sys/class/hwmon") {
|
||||
for hw_entry in hwmon_dir.flatten() {
|
||||
let hw_path = hw_entry.path();
|
||||
|
||||
// Check hwmon driver name
|
||||
if let Ok(name) = read_sysfs_file_trimmed(hw_path.join("name")) {
|
||||
// Intel CPU temperature driver
|
||||
if name == "coretemp" {
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// AMD CPU temperature driver
|
||||
// TODO: 'zenergy' can also report those stats, I think?
|
||||
else if name == "k10temp" || name == "zenpower" || name == "amdgpu" {
|
||||
// AMD's k10temp doesn't always label cores individually
|
||||
// First try to find core-specific temps
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Tdie") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try Tctl temperature (CPU control temp)
|
||||
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "Tctl") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try CPU temperature
|
||||
if let Some(temp) = get_generic_sensor_temperature(&hw_path, "CPU") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to any available temperature input without a specific label
|
||||
temperature_celsius = get_fallback_temperature(&hw_path);
|
||||
if temperature_celsius.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Other CPU temperature drivers
|
||||
else if name.contains("cpu") || name.contains("temp") {
|
||||
// Try to find a label that matches this core
|
||||
if let Some(temp) = get_temperature_for_core(&hw_path, core_id, "Core") {
|
||||
temperature_celsius = Some(temp);
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to any temperature reading if specific core not found
|
||||
temperature_celsius = get_fallback_temperature(&hw_path);
|
||||
if temperature_celsius.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try /sys/devices/platform paths for thermal zones as a last resort
|
||||
if temperature_celsius.is_none() {
|
||||
if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
|
||||
for entry in thermal_zones.flatten() {
|
||||
let zone_path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
if name.starts_with("thermal_zone") {
|
||||
// Try to match by type
|
||||
if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
|
||||
if zone_type.contains("cpu")
|
||||
|| zone_type.contains("x86")
|
||||
|| zone_type.contains("core")
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
|
||||
temperature_celsius = Some(temp_mc as f32 / 1000.0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usage_percent: Option<f32> = {
|
||||
let prev_idle = prev_times.idle_time();
|
||||
let current_idle = current_times.idle_time();
|
||||
|
||||
let prev_total = prev_times.total_time();
|
||||
let current_total = current_times.total_time();
|
||||
|
||||
let total_diff = current_total.saturating_sub(prev_total);
|
||||
let idle_diff = current_idle.saturating_sub(prev_idle);
|
||||
|
||||
// Avoid division by zero if no time has passed or counters haven't changed
|
||||
if total_diff == 0 {
|
||||
None
|
||||
} else {
|
||||
let usage = 100.0 * (1.0 - (idle_diff as f32 / total_diff as f32));
|
||||
Some(usage.clamp(0.0, 100.0)) // clamp between 0 and 100
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CpuCoreInfo {
|
||||
core_id,
|
||||
current_frequency_mhz,
|
||||
min_frequency_mhz,
|
||||
max_frequency_mhz,
|
||||
usage_percent,
|
||||
temperature_celsius,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds core-specific temperature
|
||||
fn get_temperature_for_core(hw_path: &Path, core_id: u32, label_prefix: &str) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
// Increased range to handle systems with many sensors
|
||||
let label_path = hw_path.join(format!("temp{i}_label"));
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if label_path.exists() && input_path.exists() {
|
||||
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
|
||||
// Match various common label formats:
|
||||
// "Core X", "core X", "Core-X", "CPU Core X", etc.
|
||||
let core_pattern = format!("{label_prefix} {core_id}");
|
||||
let alt_pattern = format!("{label_prefix}-{core_id}");
|
||||
|
||||
if label.eq_ignore_ascii_case(&core_pattern)
|
||||
|| label.eq_ignore_ascii_case(&alt_pattern)
|
||||
|| label
|
||||
.to_lowercase()
|
||||
.contains(&format!("core {core_id}").to_lowercase())
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Finds generic sensor temperatures by label
|
||||
fn get_generic_sensor_temperature(hw_path: &Path, label_name: &str) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
let label_path = hw_path.join(format!("temp{i}_label"));
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if label_path.exists() && input_path.exists() {
|
||||
if let Ok(label) = read_sysfs_file_trimmed(&label_path) {
|
||||
if label.eq_ignore_ascii_case(label_name)
|
||||
|| label.to_lowercase().contains(&label_name.to_lowercase())
|
||||
{
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !label_path.exists() && input_path.exists() {
|
||||
// Some sensors might not have labels but still have valid temp inputs
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Fallback to any temperature reading from a sensor
|
||||
fn get_fallback_temperature(hw_path: &Path) -> Option<f32> {
|
||||
for i in 1..=32 {
|
||||
let input_path = hw_path.join(format!("temp{i}_input"));
|
||||
|
||||
if input_path.exists() {
|
||||
if let Ok(temp_mc) = read_sysfs_value::<i32>(&input_path) {
|
||||
return Some(temp_mc as f32 / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_all_cpu_core_info() -> Result<Vec<CpuCoreInfo>> {
|
||||
let initial_cpu_times = read_all_cpu_times()?;
|
||||
thread::sleep(Duration::from_millis(250)); // interval for CPU usage calculation
|
||||
let final_cpu_times = read_all_cpu_times()?;
|
||||
|
||||
let num_cores = get_logical_core_count()
|
||||
.map_err(|_| SysMonitorError::ReadError("Could not get the number of cores".to_string()))?;
|
||||
|
||||
let mut core_infos = Vec::with_capacity(num_cores as usize);
|
||||
|
||||
for core_id in 0..num_cores {
|
||||
if let (Some(prev), Some(curr)) = (
|
||||
initial_cpu_times.get(&core_id),
|
||||
final_cpu_times.get(&core_id),
|
||||
) {
|
||||
match get_cpu_core_info(core_id, prev, curr) {
|
||||
Ok(info) => core_infos.push(info),
|
||||
Err(e) => {
|
||||
// Log or handle error for a single core, maybe push a partial info or skip
|
||||
eprintln!("Error getting info for core {core_id}: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log or handle missing times for a core
|
||||
eprintln!("Missing CPU time data for core {core_id}");
|
||||
}
|
||||
}
|
||||
Ok(core_infos)
|
||||
}
|
||||
|
||||
pub fn get_cpu_global_info(cpu_cores: &[CpuCoreInfo]) -> CpuGlobalInfo {
|
||||
// Find a valid CPU to read global settings from
|
||||
// Try cpu0 first, then fall back to any available CPU with cpufreq
|
||||
let mut cpufreq_base_path_buf = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/");
|
||||
|
||||
if !cpufreq_base_path_buf.exists() {
|
||||
let core_count = get_logical_core_count().unwrap_or_else(|e| {
|
||||
eprintln!("Warning: {e}");
|
||||
0
|
||||
});
|
||||
|
||||
for i in 0..core_count {
|
||||
let test_path = PathBuf::from(format!("/sys/devices/system/cpu/cpu{i}/cpufreq/"));
|
||||
if test_path.exists() {
|
||||
cpufreq_base_path_buf = test_path;
|
||||
break; // Exit the loop as soon as we find a valid path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let turbo_status_path = Path::new("/sys/devices/system/cpu/intel_pstate/no_turbo");
|
||||
let boost_path = Path::new("/sys/devices/system/cpu/cpufreq/boost");
|
||||
|
||||
let current_governor = if cpufreq_base_path_buf.join("scaling_governor").exists() {
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_governor")).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let available_governors = if cpufreq_base_path_buf
|
||||
.join("scaling_available_governors")
|
||||
.exists()
|
||||
{
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("scaling_available_governors"))
|
||||
.map_or_else(
|
||||
|_| vec![],
|
||||
|s| s.split_whitespace().map(String::from).collect(),
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let turbo_status = if turbo_status_path.exists() {
|
||||
// 0 means turbo enabled, 1 means disabled for intel_pstate
|
||||
read_sysfs_value::<u8>(turbo_status_path)
|
||||
.map(|val| val == 0)
|
||||
.ok()
|
||||
} else if boost_path.exists() {
|
||||
// 1 means turbo enabled, 0 means disabled for generic cpufreq boost
|
||||
read_sysfs_value::<u8>(boost_path).map(|val| val == 1).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// EPP (Energy Performance Preference)
|
||||
let energy_perf_pref =
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_preference")).ok();
|
||||
|
||||
// EPB (Energy Performance Bias)
|
||||
let energy_perf_bias =
|
||||
read_sysfs_file_trimmed(cpufreq_base_path_buf.join("energy_performance_bias")).ok();
|
||||
|
||||
let platform_profile = read_sysfs_file_trimmed("/sys/firmware/acpi/platform_profile").ok();
|
||||
|
||||
// Calculate average CPU temperature from the core temperatures
|
||||
let average_temperature_celsius = if cpu_cores.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Filter cores with temperature readings, then calculate average
|
||||
let cores_with_temp: Vec<&CpuCoreInfo> = cpu_cores
|
||||
.iter()
|
||||
.filter(|core| core.temperature_celsius.is_some())
|
||||
.collect();
|
||||
|
||||
if cores_with_temp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Sum up all temperatures and divide by count
|
||||
let sum: f32 = cores_with_temp
|
||||
.iter()
|
||||
.map(|core| core.temperature_celsius.unwrap())
|
||||
.sum();
|
||||
Some(sum / cores_with_temp.len() as f32)
|
||||
}
|
||||
};
|
||||
|
||||
// Return the constructed CpuGlobalInfo
|
||||
CpuGlobalInfo {
|
||||
current_governor,
|
||||
available_governors,
|
||||
turbo_status,
|
||||
epp: energy_perf_pref,
|
||||
epb: energy_perf_bias,
|
||||
platform_profile,
|
||||
average_temperature_celsius,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_battery_info(config: &AppConfig) -> Result<Vec<BatteryInfo>> {
|
||||
let mut batteries = Vec::new();
|
||||
let power_supply_path = Path::new("/sys/class/power_supply");
|
||||
|
||||
if !power_supply_path.exists() {
|
||||
return Ok(batteries); // no power supply directory
|
||||
}
|
||||
|
||||
let ignored_supplies = config.ignored_power_supplies.clone().unwrap_or_default();
|
||||
|
||||
// Determine overall AC connection status
|
||||
let mut overall_ac_connected = false;
|
||||
for entry in fs::read_dir(power_supply_path)? {
|
||||
let entry = entry?;
|
||||
let ps_path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
// Check for AC adapter type (common names: AC, ACAD, ADP)
|
||||
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
|
||||
if ps_type == "Mains"
|
||||
|| ps_type == "USB_PD_DRP"
|
||||
|| ps_type == "USB_PD"
|
||||
|| ps_type == "USB_DCP"
|
||||
|| ps_type == "USB_CDP"
|
||||
|| ps_type == "USB_ACA"
|
||||
{
|
||||
// USB types can also provide power
|
||||
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
|
||||
if online == 1 {
|
||||
overall_ac_connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if name.starts_with("AC") || name.contains("ACAD") || name.contains("ADP") {
|
||||
// Fallback for type file missing
|
||||
if let Ok(online) = read_sysfs_value::<u8>(ps_path.join("online")) {
|
||||
if online == 1 {
|
||||
overall_ac_connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No AC adapter detected but we're on a desktop system
|
||||
// Default to AC power for desktops
|
||||
if !overall_ac_connected {
|
||||
overall_ac_connected = is_likely_desktop_system();
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(power_supply_path)? {
|
||||
let entry = entry?;
|
||||
let ps_path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
if ignored_supplies.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(ps_type) = read_sysfs_file_trimmed(ps_path.join("type")) {
|
||||
if ps_type == "Battery" {
|
||||
// Skip peripheral batteries that aren't real laptop batteries
|
||||
if is_peripheral_battery(&ps_path, &name) {
|
||||
debug!("Skipping peripheral battery: {name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
let status_str = read_sysfs_file_trimmed(ps_path.join("status")).ok();
|
||||
let capacity_percent = read_sysfs_value::<u8>(ps_path.join("capacity")).ok();
|
||||
|
||||
let power_rate_watts = if ps_path.join("power_now").exists() {
|
||||
read_sysfs_value::<i32>(ps_path.join("power_now")) // uW
|
||||
.map(|uw| uw as f32 / 1_000_000.0)
|
||||
.ok()
|
||||
} else if ps_path.join("current_now").exists()
|
||||
&& ps_path.join("voltage_now").exists()
|
||||
{
|
||||
let current_ua = read_sysfs_value::<i32>(ps_path.join("current_now")).ok(); // uA
|
||||
let voltage_uv = read_sysfs_value::<i32>(ps_path.join("voltage_now")).ok(); // uV
|
||||
if let (Some(c), Some(v)) = (current_ua, voltage_uv) {
|
||||
// Power (W) = (Voltage (V) * Current (A))
|
||||
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
|
||||
Some((f64::from(c) * f64::from(v) / 1_000_000_000_000.0) as f32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let charge_start_threshold =
|
||||
read_sysfs_value::<u8>(ps_path.join("charge_control_start_threshold")).ok();
|
||||
let charge_stop_threshold =
|
||||
read_sysfs_value::<u8>(ps_path.join("charge_control_end_threshold")).ok();
|
||||
|
||||
batteries.push(BatteryInfo {
|
||||
name: name.clone(),
|
||||
ac_connected: overall_ac_connected,
|
||||
charging_state: status_str,
|
||||
capacity_percent,
|
||||
power_rate_watts,
|
||||
charge_start_threshold,
|
||||
charge_stop_threshold,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found no batteries but have power supplies, we're likely on a desktop
|
||||
if batteries.is_empty() && overall_ac_connected {
|
||||
debug!("No laptop batteries found, likely a desktop system");
|
||||
}
|
||||
|
||||
Ok(batteries)
|
||||
}
|
||||
|
||||
/// Check if a battery is likely a peripheral (mouse, keyboard, etc) not a laptop battery
|
||||
fn is_peripheral_battery(ps_path: &Path, name: &str) -> bool {
|
||||
// Convert name to lowercase once for case-insensitive matching
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// Common peripheral battery names
|
||||
if name_lower.contains("mouse")
|
||||
|| name_lower.contains("keyboard")
|
||||
|| name_lower.contains("trackpad")
|
||||
|| name_lower.contains("gamepad")
|
||||
|| name_lower.contains("controller")
|
||||
|| name_lower.contains("headset")
|
||||
|| name_lower.contains("headphone")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Small capacity batteries are likely not laptop batteries
|
||||
if let Ok(energy_full) = read_sysfs_value::<i32>(ps_path.join("energy_full")) {
|
||||
// Most laptop batteries are at least 20,000,000 µWh (20 Wh)
|
||||
// Peripheral batteries are typically much smaller
|
||||
if energy_full < 10_000_000 {
|
||||
// 10 Wh in µWh
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for model name that indicates a peripheral
|
||||
if let Ok(model) = read_sysfs_file_trimmed(ps_path.join("model_name")) {
|
||||
if model.contains("bluetooth") || model.contains("wireless") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Determine if this is likely a desktop system rather than a laptop
|
||||
fn is_likely_desktop_system() -> bool {
|
||||
// Check for DMI system type information
|
||||
if let Ok(chassis_type) = fs::read_to_string("/sys/class/dmi/id/chassis_type") {
|
||||
let chassis_type = chassis_type.trim();
|
||||
|
||||
// Chassis types:
|
||||
// 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower
|
||||
// 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One
|
||||
// 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis
|
||||
match chassis_type {
|
||||
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => return true, // desktop form factors
|
||||
"9" | "10" | "14" => return false, // laptop form factors
|
||||
_ => {} // Unknown, continue with other checks
|
||||
}
|
||||
}
|
||||
|
||||
// Check CPU power policies, desktops often don't have these
|
||||
let power_saving_exists = Path::new("/sys/module/intel_pstate/parameters/no_hwp").exists()
|
||||
|| Path::new("/sys/devices/system/cpu/cpufreq/conservative").exists();
|
||||
|
||||
if !power_saving_exists {
|
||||
return true; // likely a desktop
|
||||
}
|
||||
|
||||
// Check battery-specific ACPI paths that laptops typically have
|
||||
let laptop_acpi_paths = [
|
||||
"/sys/class/power_supply/BAT0",
|
||||
"/sys/class/power_supply/BAT1",
|
||||
"/proc/acpi/battery",
|
||||
];
|
||||
|
||||
for path in &laptop_acpi_paths {
|
||||
if Path::new(path).exists() {
|
||||
return false; // Likely a laptop
|
||||
}
|
||||
}
|
||||
|
||||
// Default to assuming desktop if we can't determine
|
||||
true
|
||||
}
|
||||
|
||||
pub fn get_system_load() -> Result<SystemLoad> {
|
||||
let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?;
|
||||
let parts: Vec<&str> = loadavg_str.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(SysMonitorError::ParseError(
|
||||
"Could not parse /proc/loadavg: expected at least 3 parts".to_string(),
|
||||
));
|
||||
}
|
||||
let load_avg_1min = parts[0].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 1min load: {}", parts[0]))
|
||||
})?;
|
||||
let load_avg_5min = parts[1].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 5min load: {}", parts[1]))
|
||||
})?;
|
||||
let load_avg_15min = parts[2].parse().map_err(|_| {
|
||||
SysMonitorError::ParseError(format!("Failed to parse 15min load: {}", parts[2]))
|
||||
})?;
|
||||
|
||||
Ok(SystemLoad {
|
||||
load_avg_1min,
|
||||
load_avg_5min,
|
||||
load_avg_15min,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn collect_system_report(config: &AppConfig) -> Result<SystemReport> {
|
||||
let system_info = get_system_info();
|
||||
let cpu_cores = get_all_cpu_core_info()?;
|
||||
let cpu_global = get_cpu_global_info(&cpu_cores);
|
||||
let batteries = get_battery_info(config)?;
|
||||
let system_load = get_system_load()?;
|
||||
|
||||
Ok(SystemReport {
|
||||
system_info,
|
||||
cpu_cores,
|
||||
cpu_global,
|
||||
batteries,
|
||||
system_load,
|
||||
timestamp: SystemTime::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_cpu_model() -> Result<String> {
|
||||
let path = Path::new("/proc/cpuinfo");
|
||||
let content = fs::read_to_string(path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!("Cannot read contents of {}.", path.display()))
|
||||
})?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("model name") {
|
||||
if let Some(val) = line.split(':').nth(1) {
|
||||
let cpu_model = val.trim().to_string();
|
||||
return Ok(cpu_model);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SysMonitorError::ParseError(
|
||||
"Could not find CPU model name in /proc/cpuinfo.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_linux_distribution() -> Result<String> {
|
||||
let os_release_path = Path::new("/etc/os-release");
|
||||
let content = fs::read_to_string(os_release_path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!(
|
||||
"Cannot read contents of {}.",
|
||||
os_release_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("PRETTY_NAME=") {
|
||||
if let Some(val) = line.split('=').nth(1) {
|
||||
let linux_distribution = val.trim_matches('"').to_string();
|
||||
return Ok(linux_distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lsb_release_path = Path::new("/etc/lsb-release");
|
||||
let content = fs::read_to_string(lsb_release_path).map_err(|_| {
|
||||
SysMonitorError::ReadError(format!(
|
||||
"Cannot read contents of {}.",
|
||||
lsb_release_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("DISTRIB_DESCRIPTION=") {
|
||||
if let Some(val) = line.split('=').nth(1) {
|
||||
let linux_distribution = val.trim_matches('"').to_string();
|
||||
return Ok(linux_distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(SysMonitorError::ParseError(format!(
|
||||
"Could not find distribution name in {} or {}.",
|
||||
os_release_path.display(),
|
||||
lsb_release_path.display()
|
||||
)))
|
||||
}
|
||||
// Try /sys/devices/platform paths for thermal zones as a last resort
|
||||
// if temperature_celsius.is_none() {
|
||||
// if let Ok(thermal_zones) = fs::read_dir("/sys/devices/virtual/thermal") {
|
||||
// for entry in thermal_zones.flatten() {
|
||||
// let zone_path = entry.path();
|
||||
// let name = entry.file_name().into_string().unwrap_or_default();
|
||||
|
||||
// if name.starts_with("thermal_zone") {
|
||||
// // Try to match by type
|
||||
// if let Ok(zone_type) = read_sysfs_file_trimmed(zone_path.join("type")) {
|
||||
// if zone_type.contains("cpu")
|
||||
// || zone_type.contains("x86")
|
||||
// || zone_type.contains("core")
|
||||
// {
|
||||
// if let Ok(temp_mc) = read_sysfs_value::<i32>(zone_path.join("temp")) {
|
||||
// temperature_celsius = Some(temp_mc as f32 / 1000.0);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
|
370
src/power_supply.rs
Normal file
370
src/power_supply.rs
Normal file
|
@ -0,0 +1,370 @@
|
|||
use anyhow::{Context, anyhow, bail};
|
||||
use yansi::Paint as _;
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::fs;
|
||||
|
||||
/// Represents a pattern of path suffixes used to control charge thresholds
|
||||
/// for different device vendors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PowerSupplyThresholdConfig {
|
||||
pub manufacturer: &'static str,
|
||||
pub path_start: &'static str,
|
||||
pub path_end: &'static str,
|
||||
}
|
||||
|
||||
/// Power supply threshold configs.
|
||||
const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "Standard",
|
||||
path_start: "charge_control_start_threshold",
|
||||
path_end: "charge_control_end_threshold",
|
||||
},
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "ASUS",
|
||||
path_start: "charge_control_start_percentage",
|
||||
path_end: "charge_control_end_percentage",
|
||||
},
|
||||
// Combine Huawei and ThinkPad since they use identical paths.
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "ThinkPad/Huawei",
|
||||
path_start: "charge_start_threshold",
|
||||
path_end: "charge_stop_threshold",
|
||||
},
|
||||
// Framework laptop support.
|
||||
PowerSupplyThresholdConfig {
|
||||
manufacturer: "Framework",
|
||||
path_start: "charge_behaviour_start_threshold",
|
||||
path_end: "charge_behaviour_end_threshold",
|
||||
},
|
||||
];
|
||||
|
||||
/// Represents a power supply that supports charge threshold control.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PowerSupply {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
|
||||
pub type_: String,
|
||||
pub is_from_peripheral: bool,
|
||||
|
||||
pub charge_state: Option<String>,
|
||||
pub charge_percent: Option<f64>,
|
||||
|
||||
pub charge_threshold_start: f64,
|
||||
pub charge_threshold_end: f64,
|
||||
|
||||
pub drain_rate_watts: Option<f64>,
|
||||
|
||||
pub threshold_config: Option<PowerSupplyThresholdConfig>,
|
||||
}
|
||||
|
||||
impl PowerSupply {
|
||||
pub fn is_ac(&self) -> bool {
|
||||
!self.is_from_peripheral
|
||||
&& matches!(
|
||||
&*self.type_,
|
||||
"Mains" | "USB_PD_DRP" | "USB_PD" | "USB_DCP" | "USB_CDP" | "USB_ACA"
|
||||
)
|
||||
|| self.type_.starts_with("AC")
|
||||
|| self.type_.contains("ACAD")
|
||||
|| self.type_.contains("ADP")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PowerSupply {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "power supply '{name}'", name = self.name.yellow())?;
|
||||
|
||||
if let Some(config) = self.threshold_config.as_ref() {
|
||||
write!(
|
||||
f,
|
||||
" from manufacturer '{manufacturer}'",
|
||||
manufacturer = config.manufacturer.green(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const POWER_SUPPLY_PATH: &str = "/sys/class/power_supply";
|
||||
|
||||
impl PowerSupply {
|
||||
pub fn from_name(name: String) -> anyhow::Result<Self> {
|
||||
let mut power_supply = Self {
|
||||
path: Path::new(POWER_SUPPLY_PATH).join(&name),
|
||||
name,
|
||||
type_: String::new(),
|
||||
|
||||
charge_state: None,
|
||||
charge_percent: None,
|
||||
|
||||
charge_threshold_start: 0.0,
|
||||
charge_threshold_end: 1.0,
|
||||
|
||||
drain_rate_watts: None,
|
||||
|
||||
is_from_peripheral: false,
|
||||
|
||||
threshold_config: None,
|
||||
};
|
||||
|
||||
power_supply.rescan()?;
|
||||
|
||||
Ok(power_supply)
|
||||
}
|
||||
|
||||
pub fn from_path(path: PathBuf) -> anyhow::Result<Self> {
|
||||
let mut power_supply = PowerSupply {
|
||||
name: path
|
||||
.file_name()
|
||||
.with_context(|| {
|
||||
format!("failed to get file name of '{path}'", path = path.display(),)
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
|
||||
path,
|
||||
type_: String::new(),
|
||||
|
||||
charge_state: None,
|
||||
charge_percent: None,
|
||||
|
||||
charge_threshold_start: 0.0,
|
||||
charge_threshold_end: 1.0,
|
||||
|
||||
drain_rate_watts: None,
|
||||
|
||||
is_from_peripheral: false,
|
||||
|
||||
threshold_config: None,
|
||||
};
|
||||
|
||||
power_supply.rescan()?;
|
||||
|
||||
Ok(power_supply)
|
||||
}
|
||||
|
||||
pub fn all() -> anyhow::Result<Vec<PowerSupply>> {
|
||||
let mut power_supplies = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(POWER_SUPPLY_PATH)
|
||||
.with_context(|| format!("failed to read '{POWER_SUPPLY_PATH}'"))?
|
||||
.with_context(|| format!("'{POWER_SUPPLY_PATH}' doesn't exist, are you on linux?"))?
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
|
||||
Err(error) => {
|
||||
log::warn!("failed to read power supply entry: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
power_supplies.push(PowerSupply::from_path(entry.path())?);
|
||||
}
|
||||
|
||||
Ok(power_supplies)
|
||||
}
|
||||
|
||||
pub fn rescan(&mut self) -> anyhow::Result<()> {
|
||||
if !self.path.exists() {
|
||||
bail!("{self} does not exist");
|
||||
}
|
||||
|
||||
self.type_ = {
|
||||
let type_path = self.path.join("type");
|
||||
|
||||
fs::read(&type_path)
|
||||
.with_context(|| format!("failed to read '{path}'", path = type_path.display()))?
|
||||
.with_context(|| format!("'{path}' doesn't exist", path = type_path.display()))?
|
||||
};
|
||||
|
||||
self.is_from_peripheral = 'is_from_peripheral: {
|
||||
let name_lower = self.name.to_lowercase();
|
||||
|
||||
// Common peripheral battery names.
|
||||
if name_lower.contains("mouse")
|
||||
|| name_lower.contains("keyboard")
|
||||
|| name_lower.contains("trackpad")
|
||||
|| name_lower.contains("gamepad")
|
||||
|| name_lower.contains("controller")
|
||||
|| name_lower.contains("headset")
|
||||
|| name_lower.contains("headphone")
|
||||
{
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
|
||||
// Small capacity batteries are likely not laptop batteries.
|
||||
if let Some(energy_full) = fs::read_n::<u64>(self.path.join("energy_full"))
|
||||
.with_context(|| format!("failed to read the max charge {self} can hold"))?
|
||||
{
|
||||
// Most laptop batteries are at least 20,000,000 µWh (20 Wh).
|
||||
// Peripheral batteries are typically much smaller.
|
||||
if energy_full < 10_000_000 {
|
||||
// 10 Wh in µWh.
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
}
|
||||
// Check for model name that indicates a peripheral
|
||||
if let Some(model) = fs::read(self.path.join("model_name"))
|
||||
.with_context(|| format!("failed to read the model name of {self}"))?
|
||||
{
|
||||
if model.contains("bluetooth") || model.contains("wireless") {
|
||||
break 'is_from_peripheral true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
};
|
||||
|
||||
if self.type_ == "Battery" {
|
||||
self.charge_state = fs::read(self.path.join("status"))
|
||||
.with_context(|| format!("failed to read {self} charge status"))?;
|
||||
|
||||
self.charge_percent = fs::read_n::<u64>(self.path.join("capacity"))
|
||||
.with_context(|| format!("failed to read {self} charge percent"))?
|
||||
.map(|percent| percent as f64 / 100.0);
|
||||
|
||||
self.charge_threshold_start =
|
||||
fs::read_n::<u64>(self.path.join("charge_control_start_threshold"))
|
||||
.with_context(|| format!("failed to read {self} charge threshold start"))?
|
||||
.map_or(0.0, |percent| percent as f64 / 100.0);
|
||||
|
||||
self.charge_threshold_end =
|
||||
fs::read_n::<u64>(self.path.join("charge_control_end_threshold"))
|
||||
.with_context(|| format!("failed to read {self} charge threshold end"))?
|
||||
.map_or(100.0, |percent| percent as f64 / 100.0);
|
||||
|
||||
self.drain_rate_watts = match fs::read_n::<i64>(self.path.join("power_now"))
|
||||
.with_context(|| format!("failed to read {self} power drain"))?
|
||||
{
|
||||
Some(drain) => Some(drain as f64),
|
||||
|
||||
None => {
|
||||
let current_ua = fs::read_n::<i32>(self.path.join("current_now"))
|
||||
.with_context(|| format!("failed to read {self} current"))?;
|
||||
|
||||
let voltage_uv = fs::read_n::<i32>(self.path.join("voltage_now"))
|
||||
.with_context(|| format!("failed to read {self} voltage"))?;
|
||||
|
||||
current_ua.zip(voltage_uv).map(|(current, voltage)| {
|
||||
// Power (W) = Voltage (V) * Current (A)
|
||||
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
|
||||
current as f64 * voltage as f64 / 1e12
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
self.threshold_config = POWER_SUPPLY_THRESHOLD_CONFIGS
|
||||
.iter()
|
||||
.find(|config| {
|
||||
self.path.join(config.path_start).exists()
|
||||
&& self.path.join(config.path_end).exists()
|
||||
})
|
||||
.copied();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn charge_threshold_path_start(&self) -> Option<PathBuf> {
|
||||
self.threshold_config
|
||||
.map(|config| self.path.join(config.path_start))
|
||||
}
|
||||
|
||||
pub fn charge_threshold_path_end(&self) -> Option<PathBuf> {
|
||||
self.threshold_config
|
||||
.map(|config| self.path.join(config.path_end))
|
||||
}
|
||||
|
||||
pub fn set_charge_threshold_start(
|
||||
&mut self,
|
||||
charge_threshold_start: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
fs::write(
|
||||
&self.charge_threshold_path_start().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"power supply '{name}' does not support changing charge threshold levels",
|
||||
name = self.name,
|
||||
)
|
||||
})?,
|
||||
&((charge_threshold_start * 100.0) as u8).to_string(),
|
||||
)
|
||||
.with_context(|| format!("failed to set charge threshold start for {self}"))?;
|
||||
|
||||
self.charge_threshold_start = charge_threshold_start;
|
||||
|
||||
log::info!("set battery threshold start for {self} to {charge_threshold_start}%");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_charge_threshold_end(&mut self, charge_threshold_end: f64) -> anyhow::Result<()> {
|
||||
fs::write(
|
||||
&self.charge_threshold_path_end().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"power supply '{name}' does not support changing charge threshold levels",
|
||||
name = self.name,
|
||||
)
|
||||
})?,
|
||||
&((charge_threshold_end * 100.0) as u8).to_string(),
|
||||
)
|
||||
.with_context(|| format!("failed to set charge threshold end for {self}"))?;
|
||||
|
||||
self.charge_threshold_end = charge_threshold_end;
|
||||
|
||||
log::info!("set battery threshold end for {self} to {charge_threshold_end}%");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_available_platform_profiles() -> anyhow::Result<Vec<String>> {
|
||||
let path = "/sys/firmware/acpi/platform_profile_choices";
|
||||
|
||||
let Some(content) =
|
||||
fs::read(path).context("failed to read available ACPI platform profiles")?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
Ok(content
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sets the platform profile.
|
||||
/// This changes the system performance, temperature, fan, and other hardware replated characteristics.
|
||||
///
|
||||
/// Also see [`The Kernel docs`] for this.
|
||||
///
|
||||
/// [`The Kernel docs`]: <https://docs.kernel.org/userspace-api/sysfs-platform_profile.html>
|
||||
pub fn set_platform_profile(profile: &str) -> anyhow::Result<()> {
|
||||
let profiles = Self::get_available_platform_profiles()?;
|
||||
|
||||
if !profiles
|
||||
.iter()
|
||||
.any(|avail_profile| avail_profile == profile)
|
||||
{
|
||||
bail!(
|
||||
"profile '{profile}' is not available for system. valid profiles: {profiles}",
|
||||
profiles = profiles.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
fs::write("/sys/firmware/acpi/platform_profile", profile)
|
||||
.context("this probably means that your system does not support changing ACPI profiles")
|
||||
}
|
||||
|
||||
pub fn platform_profile() -> anyhow::Result<String> {
|
||||
fs::read("/sys/firmware/acpi/platform_profile")
|
||||
.context("failed to read platform profile")?
|
||||
.context("failed to find platform profile")
|
||||
}
|
||||
}
|
238
src/system.rs
Normal file
238
src/system.rs
Normal 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(())
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
use std::io;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ControlError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to write to sysfs path: {0}")]
|
||||
WriteError(String),
|
||||
|
||||
#[error("Failed to read sysfs path: {0}")]
|
||||
ReadError(String),
|
||||
|
||||
#[error("Invalid value for setting: {0}")]
|
||||
InvalidValueError(String),
|
||||
|
||||
#[error("Control action not supported: {0}")]
|
||||
NotSupported(String),
|
||||
|
||||
#[error("Permission denied: {0}. Try running with sudo.")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("Invalid platform control profile {0} supplied, please provide a valid one.")]
|
||||
InvalidProfile(String),
|
||||
|
||||
#[error("Invalid governor: {0}")]
|
||||
InvalidGovernor(String),
|
||||
|
||||
#[error("Failed to parse value: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Path missing: {0}")]
|
||||
PathMissing(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SysMonitorError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to read sysfs path: {0}")]
|
||||
ReadError(String),
|
||||
|
||||
#[error("Failed to parse value: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Failed to parse /proc/stat: {0}")]
|
||||
ProcStatParseError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("CPU control error: {0}")]
|
||||
ControlError(#[from] ControlError),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigurationError(String),
|
||||
}
|
||||
|
||||
// A unified error type for the entire application
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("{0}")]
|
||||
Control(#[from] ControlError),
|
||||
|
||||
#[error("{0}")]
|
||||
Monitor(#[from] SysMonitorError),
|
||||
|
||||
#[error("{0}")]
|
||||
Engine(#[from] EngineError),
|
||||
|
||||
#[error("{0}")]
|
||||
Config(#[from] crate::config::ConfigError),
|
||||
|
||||
#[error("{0}")]
|
||||
Generic(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
pub mod error;
|
||||
pub mod sysfs;
|
|
@ -1,80 +0,0 @@
|
|||
use crate::util::error::ControlError;
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
/// Write a value to a sysfs file with consistent error handling
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - The file path to write to
|
||||
/// * `value` - The string value to write
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `ControlError` variant based on the specific error:
|
||||
/// - `ControlError::PermissionDenied` if permission is denied
|
||||
/// - `ControlError::PathMissing` if the path doesn't exist
|
||||
/// - `ControlError::WriteError` for other I/O errors
|
||||
pub fn write_sysfs_value(path: impl AsRef<Path>, value: &str) -> Result<(), ControlError> {
|
||||
let p = path.as_ref();
|
||||
|
||||
fs::write(p, value).map_err(|e| {
|
||||
let error_msg = format!("Path: {:?}, Value: '{}', Error: {}", p.display(), value, e);
|
||||
match e.kind() {
|
||||
io::ErrorKind::PermissionDenied => ControlError::PermissionDenied(error_msg),
|
||||
io::ErrorKind::NotFound => {
|
||||
ControlError::PathMissing(format!("Path '{}' does not exist", p.display()))
|
||||
}
|
||||
_ => ControlError::WriteError(error_msg),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a value from a sysfs file with consistent error handling
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - The file path to read from
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the trimmed contents of the file as a String
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `ControlError` variant based on the specific error:
|
||||
/// - `ControlError::PermissionDenied` if permission is denied
|
||||
/// - `ControlError::PathMissing` if the path doesn't exist
|
||||
/// - `ControlError::ReadError` for other I/O errors
|
||||
pub fn read_sysfs_value(path: impl AsRef<Path>) -> Result<String, ControlError> {
|
||||
let p = path.as_ref();
|
||||
fs::read_to_string(p)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Path: {:?}, Error: {}", p.display(), e);
|
||||
match e.kind() {
|
||||
io::ErrorKind::PermissionDenied => ControlError::PermissionDenied(error_msg),
|
||||
io::ErrorKind::NotFound => {
|
||||
ControlError::PathMissing(format!("Path '{}' does not exist", p.display()))
|
||||
}
|
||||
_ => ControlError::ReadError(error_msg),
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Safely check if a path exists and is writable
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - The file path to check
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns true if the path exists and is writable, false otherwise
|
||||
pub fn path_exists_and_writable(path: &Path) -> bool {
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to open the file with write access to verify write permission
|
||||
fs::OpenOptions::new().write(true).open(path).is_ok()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue