diff --git a/Cargo.lock b/Cargo.lock index bb5a94f..f077741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -211,6 +232,17 @@ 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" @@ -269,10 +301,12 @@ 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]] @@ -286,12 +320,37 @@ 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" @@ -332,6 +391,12 @@ 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" @@ -365,6 +430,17 @@ 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" @@ -429,6 +505,26 @@ 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", + "clap-verbosity-flag", + "ctrlc", + "derive_more", + "dirs", + "env_logger", + "jiff", + "log", + "num_cpus", + "serde", + "thiserror", + "toml", + "yansi", +] + [[package]] name = "syn" version = "2.0.101" @@ -526,22 +622,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "watt" -version = "0.4.0" -dependencies = [ - "anyhow", - "clap", - "clap-verbosity-flag", - "ctrlc", - "derive_more", - "env_logger", - "log", - "num_cpus", - "serde", - "thiserror", - "toml", - "yansi", -] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "windows-sys" diff --git a/Cargo.toml b/Cargo.toml index ecc84ed..aeecd4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,23 @@ [package] -name = "watt" +name = "superfreq" description = "Modern CPU frequency and power management utility for Linux" -version = "0.4.0" +version = "0.3.2" edition = "2024" -authors = ["NotAShelf ", "RGBCube "] +authors = ["NotAShelf "] rust-version = "1.85" [dependencies] -anyhow = "1.0" -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" +dirs = "6.0" +clap = { version = "4.0", features = ["derive", "env"] } +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-verbosity-flag = "3.0.2" yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] } +derive_more = { version = "2.0.1", features = ["full"] } diff --git a/build.rs b/build.rs deleted file mode 100644 index 5cc203d..0000000 --- a/build.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::env; -use std::fs; -use std::path::PathBuf; - -const MULTICALL_NAMES: &[&str] = &["cpu", "power"]; - -fn main() -> Result<(), Box> { - println!("cargo:rerun-if-changed=build.rs"); - - let out_dir = PathBuf::from(env::var("OUT_DIR")?); - let target = out_dir - .parent() // target/debug/build/-/out - .and_then(|p| p.parent()) // target/debug/build/- - .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(()) -} diff --git a/config.toml b/config.toml index 04159cd..2f796b7 100644 --- a/config.toml +++ b/config.toml @@ -1,3 +1,2 @@ [[rule]] priority = 0 -if = { value = "%cpu-usage", is-more-than = 0.7 } diff --git a/flake.nix b/flake.nix index 59bc72c..b5af16a 100644 --- a/flake.nix +++ b/flake.nix @@ -6,38 +6,21 @@ nixpkgs, ... } @ inputs: let - forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"]; - pkgsForEach = forAllSystems (system: - import nixpkgs { - localSystem.system = system; - overlays = [self.overlays.default]; - }); + forAllSystems = nixpkgs.lib.genAttrs ["x86_64-linux"]; + pkgsForEach = nixpkgs.legacyPackages; in { - overlays = { - superfreq = final: _: { - superfreq = final.callPackage ./nix/package.nix {}; - }; - default = self.overlays.superfreq; - }; + packages = forAllSystems (system: { + superfreq = pkgsForEach.${system}.callPackage ./nix/package.nix {}; + default = self.packages.${system}.superfreq; + }); - 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; + devShells = forAllSystems (system: { + default = pkgsForEach.${system}.callPackage ./nix/shell.nix {}; + }); nixosModules = { superfreq = import ./nix/module.nix inputs; default = self.nixosModules.superfreq; }; - - formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra); }; } diff --git a/src/config.rs b/src/config.rs index b3d214e..2560806 100644 --- a/src/config.rs +++ b/src/config.rs @@ -155,151 +155,92 @@ impl PowerDelta { } } -macro_rules! named { - ($variant:ident => $value:literal) => { - pub mod $variant { - pub fn serialize(serializer: S) -> Result { - 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(self, value: &str) -> Result { - 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)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(untagged, rename_all = "kebab-case")] pub enum Expression { - #[serde(with = "expression::cpu_usage")] + #[serde(rename = "%cpu-usage")] CpuUsage, - #[serde(with = "expression::cpu_usage_volatility")] + #[serde(rename = "$cpu-usage-volatility")] CpuUsageVolatility, - #[serde(with = "expression::cpu_temperature")] + #[serde(rename = "$cpu-temperature")] CpuTemperature, - #[serde(with = "expression::cpu_temperature_volatility")] + #[serde(rename = "$cpu-temperature-volatility")] CpuTemperatureVolatility, - #[serde(with = "expression::cpu_idle_seconds")] + #[serde(rename = "$cpu-idle-seconds")] CpuIdleSeconds, - #[serde(with = "expression::power_supply_charge")] + #[serde(rename = "%power-supply-charge")] PowerSupplyCharge, - #[serde(with = "expression::power_supply_discharge_rate")] + #[serde(rename = "%power-supply-discharge-rate")] PowerSupplyDischargeRate, - #[serde(with = "expression::discharging")] - Discharging, + #[serde(rename = "?charging")] + Charging, + #[serde(rename = "?on-battery")] + OnBattery, - Boolean(bool), + #[serde(rename = "#false")] + False, + + #[default] + #[serde(rename = "#true")] + True, Number(f64), Plus { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "plus")] - b: Box, + value: Box, + plus: Box, }, Minus { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "minus")] - b: Box, + value: Box, + minus: Box, }, Multiply { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "multiply")] - b: Box, + value: Box, + multiply: Box, }, Power { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "power")] - b: Box, + value: Box, + power: Box, }, Divide { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "divide")] - b: Box, + value: Box, + divide: Box, }, LessThan { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "is-less-than")] - b: Box, + value: Box, + is_less_than: Box, }, + MoreThan { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "is-more-than")] - b: Box, + value: Box, + is_more_than: Box, }, Equal { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "is-equal")] - b: Box, + value: Box, + is_equal: Box, leeway: Box, }, And { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "and")] - b: Box, + value: Box, + and: Box, }, All { all: Vec, }, Or { - #[serde(rename = "value")] - a: Box, - #[serde(rename = "or")] - b: Box, + value: Box, + or: Box, }, Any { any: Vec, @@ -310,164 +251,25 @@ pub enum Expression { }, } -impl Default for Expression { - fn default() -> Self { - Self::Boolean(true) - } -} - -impl Expression { - pub fn as_number(&self) -> anyhow::Result { - let Self::Number(number) = self else { - bail!("tried to cast '{self:?}' to a number, failed") - }; - - Ok(*number) - } - - pub fn as_boolean(&self) -> anyhow::Result { - 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, - pub cpu_temperature: f64, - pub cpu_temperature_volatility: Option, - pub cpu_idle_seconds: f64, - - pub power_supply_charge: f64, - pub power_supply_discharge_rate: Option, - - pub discharging: bool, -} - -impl Expression { - pub fn eval(&self, state: &EvalState) -> anyhow::Result> { - 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, + priority: u8, #[serde(default, rename = "if", skip_serializing_if = "is_default")] - pub condition: Expression, + if_: Expression, #[serde(default, skip_serializing_if = "is_default")] - pub cpu: CpuDelta, + cpu: CpuDelta, #[serde(default, skip_serializing_if = "is_default")] - pub power: PowerDelta, + power: PowerDelta, } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(default, rename_all = "kebab-case")] pub struct DaemonConfig { #[serde(rename = "rule")] - pub rules: Vec, + rules: Vec, } impl DaemonConfig { @@ -476,8 +278,7 @@ impl DaemonConfig { 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 config: Self = toml::from_str(&contents).context("failed to parse config file")?; { let mut priorities = Vec::with_capacity(config.rules.len()); @@ -491,10 +292,6 @@ impl DaemonConfig { } } - config.rules.sort_by_key(|rule| rule.priority); - - log::debug!("loaded config: {config:#?}"); - Ok(config) } } diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..2e32854 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,51 @@ +pub struct SystemInfo { + // Overall system details + pub cpu_model: String, +} + +pub struct CpuCoreInfo { + // Per-core data + pub core_id: u32, + pub temperature_celsius: Option, +} + +pub struct CpuGlobalInfo { + // System-wide CPU settings + pub epp: Option, // Energy Performance Preference + pub epb: Option, // Energy Performance Bias + pub average_temperature_celsius: Option, // 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, // e.g., "Charging", "Discharging", "Full" + pub capacity_percent: Option, + pub power_rate_watts: Option, // positive for charging, negative for discharging + pub charge_start_threshold: Option, + pub charge_stop_threshold: Option, +} + +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, + pub cpu_global: CpuGlobalInfo, + pub batteries: Vec, + 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, +} diff --git a/src/cpu.rs b/src/cpu.rs index 70918be..7a60ee3 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,14 +1,13 @@ use anyhow::{Context, bail}; use yansi::Paint as _; -use std::{cell::OnceCell, collections::HashMap, fmt, mem, rc::Rc, string::ToString}; +use std::{cell::OnceCell, collections::HashMap, fmt, string::ToString}; use crate::fs; #[derive(Default, Debug, Clone, PartialEq)] pub struct CpuRescanCache { stat: OnceCell>, - info: OnceCell>>>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -64,7 +63,6 @@ pub struct Cpu { pub epb: Option, pub stat: CpuStat, - pub info: Option>>, pub temperature: Option, } @@ -106,7 +104,6 @@ impl Cpu { softirq: 0, steal: 0, }, - info: None, temperature: None, }; @@ -174,7 +171,6 @@ impl Cpu { } self.rescan_stat(cache)?; - self.rescan_info(cache)?; Ok(()) } @@ -349,57 +345,6 @@ impl Cpu { Ok(()) } - fn rescan_info(&mut self, cache: &CpuRescanCache) -> anyhow::Result<()> { - // OnceCell::get_or_try_init is unstable. Cope: - let info = match cache.info.get() { - Some(stat) => stat, - - None => { - let content = fs::read("/proc/cpuinfo") - .context("failed to read CPU info")? - .context("/proc/cpuinfo does not exist")?; - - let mut info = HashMap::new(); - let mut current_number = None; - let mut current_data = HashMap::new(); - - macro_rules! try_save_data { - () => { - if let Some(number) = current_number.take() { - info.insert(number, Rc::new(mem::take(&mut current_data))); - } - }; - } - - for line in content.lines() { - let parts = line.splitn(2, ':').collect::>(); - - if parts.len() == 2 { - let key = parts[0].trim(); - let value = parts[1].trim(); - - if key == "processor" { - try_save_data!(); - - current_number = value.parse::().ok(); - } else { - current_data.insert(key.to_owned(), value.to_owned()); - } - } - } - - try_save_data!(); - - cache.info.set(info).unwrap(); - cache.info.get().unwrap() - } - }; - - self.info = info.get(&self.number).cloned(); - - Ok(()) - } - pub fn set_governor(&mut self, governor: &str) -> anyhow::Result<()> { let Self { number, diff --git a/src/daemon.rs b/src/daemon.rs index 9e0370a..f2d2e3a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,18 +1,16 @@ use std::{ - cell::LazyCell, - collections::{HashMap, VecDeque}, - ops::Deref, + collections::VecDeque, + ops, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, - thread, time::{Duration, Instant}, }; use anyhow::Context; -use crate::{config, system}; +use crate::config; /// Calculate the idle time multiplier based on system idle time. /// @@ -44,8 +42,8 @@ struct Daemon { /// The last computed polling interval. last_polling_interval: Option, - /// The system state. - system: system::System, + /// Whether if we are charging right now. + charging: bool, /// CPU usage and temperature log. cpu_log: VecDeque, @@ -54,56 +52,6 @@ struct Daemon { power_supply_log: VecDeque, } -impl Daemon { - fn rescan(&mut self) -> anyhow::Result<()> { - self.system.rescan()?; - - while self.cpu_log.len() > 99 { - self.cpu_log.pop_front(); - } - - self.cpu_log.push_back(CpuLog { - at: Instant::now(), - - usage: self - .system - .cpus - .iter() - .map(|cpu| cpu.stat.usage()) - .sum::() - / self.system.cpus.len() as f64, - - temperature: self.system.cpu_temperatures.values().sum::() - / self.system.cpu_temperatures.len() as f64, - }); - - let at = Instant::now(); - - let (charge_sum, charge_nr) = - self.system - .power_supplies - .iter() - .fold((0.0, 0u32), |(sum, count), power_supply| { - if let Some(charge_percent) = power_supply.charge_percent { - (sum + charge_percent, count + 1) - } else { - (sum, count) - } - }); - - while self.power_supply_log.len() > 99 { - self.power_supply_log.pop_front(); - } - - self.power_supply_log.push_back(PowerSupplyLog { - at, - charge: charge_sum / charge_nr as f64, - }); - - Ok(()) - } -} - struct CpuLog { at: Instant, @@ -115,6 +63,8 @@ struct CpuLog { } struct CpuVolatility { + at: ops::Range, + usage: f64, temperature: f64, @@ -122,17 +72,6 @@ struct CpuVolatility { impl Daemon { fn cpu_volatility(&self) -> Option { - let recent_log_count = self - .cpu_log - .iter() - .rev() - .take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60)) - .count(); - - if recent_log_count < 2 { - return None; - } - if self.cpu_log.len() < 2 { return None; } @@ -152,6 +91,8 @@ impl Daemon { } Some(CpuVolatility { + at: self.cpu_log.front().unwrap().at..self.cpu_log.back().unwrap().at, + usage: usage_change_sum / change_count as f64, temperature: temperature_change_sum / change_count as f64, }) @@ -193,13 +134,6 @@ struct PowerSupplyLog { } impl Daemon { - fn discharging(&self) -> bool { - self.system - .power_supplies - .iter() - .any(|power_supply| power_supply.charge_state.as_deref() == Some("Discharging")) - } - /// Calculates the discharge rate, returns a number between 0 and 1. /// /// The discharge rate is averaged per hour. @@ -247,7 +181,7 @@ impl Daemon { let mut interval = Duration::from_secs(5); // We are on battery, so we must be more conservative with our polling. - if self.discharging() { + if !self.charging { match self.power_supply_discharge_rate() { Some(discharge_rate) => { if discharge_rate > 0.2 { @@ -309,8 +243,6 @@ impl Daemon { } pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { - assert!(config.rules.is_sorted_by_key(|rule| rule.priority)); - log::info!("starting daemon..."); let cancelled = Arc::new(AtomicBool::new(false)); @@ -322,87 +254,7 @@ pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { }) .context("failed to set Ctrl-C handler")?; - let mut daemon = Daemon { - last_user_activity: Instant::now(), - - last_polling_interval: None, - - system: system::System::new()?, - - cpu_log: VecDeque::new(), - power_supply_log: VecDeque::new(), - }; - - while !cancelled.load(Ordering::SeqCst) { - daemon.rescan()?; - - let sleep_until = Instant::now() + daemon.polling_interval(); - - let state = config::EvalState { - cpu_usage: daemon.cpu_log.back().unwrap().usage, - cpu_usage_volatility: daemon.cpu_volatility().map(|vol| vol.usage), - cpu_temperature: daemon.cpu_log.back().unwrap().temperature, - cpu_temperature_volatility: daemon.cpu_volatility().map(|vol| vol.temperature), - cpu_idle_seconds: daemon.last_user_activity.elapsed().as_secs_f64(), - power_supply_charge: daemon.power_supply_log.back().unwrap().charge, - power_supply_discharge_rate: daemon.power_supply_discharge_rate(), - discharging: daemon.discharging(), - }; - - let mut cpu_delta_for = HashMap::::new(); - let all_cpus = LazyCell::new(|| (0..num_cpus::get() as u32).collect::>()); - - for rule in &config.rules { - let Some(condition) = rule.condition.eval(&state)? else { - continue; - }; - - let cpu_for = rule.cpu.for_.as_ref().unwrap_or_else(|| &*all_cpus); - - for cpu in cpu_for { - let delta = cpu_delta_for.entry(*cpu).or_default(); - - delta.for_ = Some(vec![*cpu]); - - if let Some(governor) = rule.cpu.governor.as_ref() { - delta.governor = Some(governor.clone()); - } - - if let Some(epp) = rule.cpu.energy_performance_preference.as_ref() { - delta.energy_performance_preference = Some(epp.clone()); - } - - if let Some(epb) = rule.cpu.energy_performance_bias.as_ref() { - delta.energy_performance_bias = Some(epb.clone()); - } - - if let Some(mhz_minimum) = rule.cpu.frequency_mhz_minimum { - delta.frequency_mhz_minimum = Some(mhz_minimum); - } - - if let Some(mhz_maximum) = rule.cpu.frequency_mhz_maximum { - delta.frequency_mhz_maximum = Some(mhz_maximum); - } - - if let Some(turbo) = rule.cpu.turbo { - delta.turbo = Some(turbo); - } - } - - // TODO: Also merge this into one like CPU. - if condition.as_boolean()? { - rule.power.apply()?; - } - } - - for delta in cpu_delta_for.values() { - delta.apply()?; - } - - if let Some(delay) = sleep_until.checked_duration_since(Instant::now()) { - thread::sleep(delay); - } - } + while !cancelled.load(Ordering::SeqCst) {} log::info!("exiting..."); diff --git a/src/daemon_old.rs b/src/daemon_old.rs new file mode 100644 index 0000000..3a20cb4 --- /dev/null +++ b/src/daemon_old.rs @@ -0,0 +1,426 @@ +use anyhow::Context; +use anyhow::bail; + +use crate::config::AppConfig; +use crate::core::SystemReport; +use crate::engine; +use crate::monitor; +use std::collections::VecDeque; +use std::fs::File; +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Tracks historical system data for "advanced" adaptive polling +#[derive(Debug)] +struct SystemHistory { + /// Last several CPU usage measurements + cpu_usage_history: VecDeque, + /// Last several temperature readings + temperature_history: VecDeque, + /// Time of last detected user activity + last_user_activity: Instant, + /// Previous battery percentage (to calculate discharge rate) + last_battery_percentage: Option, + /// Timestamp of last battery reading + last_battery_timestamp: Option, + /// Battery discharge rate (%/hour) + battery_discharge_rate: Option, + /// Time spent in each system state + state_durations: std::collections::HashMap, + /// Last time a state transition happened + last_state_change: Instant, + /// Current system state + current_state: SystemState, + /// Last computed optimal polling interval + last_computed_interval: Option, +} + +impl SystemHistory { + /// Update system history with new report data + fn update(&mut self, report: &SystemReport) { + // Update CPU usage history + if !report.cpu_cores.is_empty() { + let mut total_usage: f32 = 0.0; + let mut core_count: usize = 0; + + for core in &report.cpu_cores { + if let Some(usage) = core.usage_percent { + total_usage += usage; + core_count += 1; + } + } + + if core_count > 0 { + let avg_usage = total_usage / core_count as f32; + + // Keep only the last 5 measurements + if self.cpu_usage_history.len() >= 5 { + self.cpu_usage_history.pop_front(); + } + self.cpu_usage_history.push_back(avg_usage); + + // Update last_user_activity if CPU usage indicates activity + // Consider significant CPU usage or sudden change as user activity + if avg_usage > 20.0 + || (self.cpu_usage_history.len() > 1 + && (avg_usage - self.cpu_usage_history[self.cpu_usage_history.len() - 2]) + .abs() + > 15.0) + { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on CPU usage"); + } + } + } + + // Update temperature history + if let Some(temp) = report.cpu_global.average_temperature_celsius { + if self.temperature_history.len() >= 5 { + self.temperature_history.pop_front(); + } + self.temperature_history.push_back(temp); + + // Significant temperature increase can indicate user activity + if self.temperature_history.len() > 1 { + let temp_change = + temp - self.temperature_history[self.temperature_history.len() - 2]; + if temp_change > 5.0 { + // 5°C rise in temperature + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on temperature change"); + } + } + } + + // Update battery discharge rate + if let Some(battery) = report.batteries.first() { + // Reset when we are charging or have just connected AC + if battery.ac_connected { + // Reset discharge tracking but continue updating the rest of + // the history so we still detect activity/load changes on AC. + self.battery_discharge_rate = None; + self.last_battery_percentage = None; + self.last_battery_timestamp = None; + } + + if let Some(current_percentage) = battery.capacity_percent { + let current_percent = f32::from(current_percentage); + + if let (Some(last_percentage), Some(last_timestamp)) = + (self.last_battery_percentage, self.last_battery_timestamp) + { + let elapsed_hours = last_timestamp.elapsed().as_secs_f32() / 3600.0; + // Only calculate discharge rate if at least 30 seconds have passed + // and we're not on AC power + if elapsed_hours > 0.0083 && !battery.ac_connected { + // 0.0083 hours = 30 seconds + // Calculate discharge rate in percent per hour + let percent_change = last_percentage - current_percent; + if percent_change > 0.0 { + // Only if battery is discharging + let hourly_rate = percent_change / elapsed_hours; + // Clamp the discharge rate to a reasonable maximum value (100%/hour) + let clamped_rate = hourly_rate.min(100.0); + self.battery_discharge_rate = Some(clamped_rate); + } + } + } + + self.last_battery_percentage = Some(current_percent); + self.last_battery_timestamp = Some(Instant::now()); + } + } + + // Update system state tracking + let new_state = determine_system_state(report, self); + if new_state != self.current_state { + // Record time spent in previous state + let time_in_state = self.last_state_change.elapsed(); + *self + .state_durations + .entry(self.current_state.clone()) + .or_insert(Duration::ZERO) += time_in_state; + + // State changes (except to Idle) likely indicate user activity + if new_state != SystemState::Idle && new_state != SystemState::LowLoad { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on system state change to {new_state:?}"); + } + + // Update state + self.current_state = new_state; + self.last_state_change = Instant::now(); + } + + // Check for significant load changes + if report.system_load.load_avg_1min > 1.0 { + self.last_user_activity = Instant::now(); + log::debug!("User activity detected based on system load"); + } + } + + /// Calculate CPU usage volatility (how much it's changing) + fn get_cpu_volatility(&self) -> f32 { + if self.cpu_usage_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.cpu_usage_history.len() { + sum_of_changes += (self.cpu_usage_history[i] - self.cpu_usage_history[i - 1]).abs(); + } + + sum_of_changes / (self.cpu_usage_history.len() - 1) as f32 + } + + /// Calculate temperature volatility + fn get_temperature_volatility(&self) -> f32 { + if self.temperature_history.len() < 2 { + return 0.0; + } + + let mut sum_of_changes = 0.0; + for i in 1..self.temperature_history.len() { + sum_of_changes += (self.temperature_history[i] - self.temperature_history[i - 1]).abs(); + } + + sum_of_changes / (self.temperature_history.len() - 1) as f32 + } + + /// Determine if the system appears to be idle + fn is_system_idle(&self) -> bool { + if self.cpu_usage_history.is_empty() { + return false; + } + + // System considered idle if the average CPU usage of last readings is below 10% + let recent_avg = + self.cpu_usage_history.iter().sum::() / self.cpu_usage_history.len() as f32; + recent_avg < 10.0 && self.get_cpu_volatility() < 5.0 + } +} + +/// Run the daemon +pub fn run_daemon(config: AppConfig) -> anyhow::Result<()> { + log::info!("Starting superfreq daemon..."); + + // Validate critical configuration values before proceeding + validate_poll_intervals( + config.daemon.min_poll_interval_sec, + config.daemon.max_poll_interval_sec, + )?; + + // Create a flag that will be set to true when a signal is received + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + + // Set up signal handlers + ctrlc::set_handler(move || { + log::info!("Received shutdown signal, exiting..."); + r.store(false, Ordering::SeqCst); + }) + .context("failed to set Ctrl-C handler")?; + + log::info!( + "Daemon initialized with poll interval: {}s", + config.daemon.poll_interval_sec + ); + + // Set up stats file if configured + if let Some(stats_path) = &config.daemon.stats_file_path { + log::info!("Stats will be written to: {stats_path}"); + } + + // Variables for adaptive polling + // Make sure that the poll interval is *never* zero to prevent a busy loop + let mut current_poll_interval = config.daemon.poll_interval_sec.max(1); + if config.daemon.poll_interval_sec == 0 { + log::warn!( + "Poll interval is set to zero in config, using 1s minimum to prevent a busy loop" + ); + } + let mut system_history = SystemHistory::default(); + + // Main loop + while running.load(Ordering::SeqCst) { + let start_time = Instant::now(); + + match monitor::collect_system_report(&config) { + Ok(report) => { + log::debug!("Collected system report, applying settings..."); + + // Store the current state before updating history + let previous_state = system_history.current_state.clone(); + + // Update system history with new data + system_history.update(&report); + + // Update the stats file if configured + if let Some(stats_path) = &config.daemon.stats_file_path { + if let Err(e) = write_stats_file(stats_path, &report) { + log::error!("Failed to write stats file: {e}"); + } + } + + match engine::determine_and_apply_settings(&report, &config, None) { + Ok(()) => { + log::debug!("Successfully applied system settings"); + + // If system state changed, log the new state + if system_history.current_state != previous_state { + log::info!( + "System state changed to: {:?}", + system_history.current_state + ); + } + } + Err(e) => { + log::error!("Error applying system settings: {e}"); + } + } + + // Check if we're on battery + let on_battery = !report.batteries.is_empty() + && report.batteries.first().is_some_and(|b| !b.ac_connected); + + // Calculate optimal polling interval if adaptive polling is enabled + if config.daemon.adaptive_interval { + match system_history.calculate_optimal_interval(&config, on_battery) { + Ok(optimal_interval) => { + // Store the new interval + system_history.last_computed_interval = Some(optimal_interval); + + log::debug!("Recalculated optimal interval: {optimal_interval}s"); + + // Don't change the interval too dramatically at once + match optimal_interval.cmp(¤t_poll_interval) { + std::cmp::Ordering::Greater => { + current_poll_interval = + (current_poll_interval + optimal_interval) / 2; + } + std::cmp::Ordering::Less => { + current_poll_interval = current_poll_interval + - ((current_poll_interval - optimal_interval) / 2).max(1); + } + std::cmp::Ordering::Equal => { + // No change needed when they're equal + } + } + } + Err(e) => { + // Log the error and stop the daemon when an invalid configuration is detected + log::error!("Critical configuration error: {e}"); + running.store(false, Ordering::SeqCst); + break; + } + } + + // Make sure that we respect the (user) configured min and max limits + current_poll_interval = current_poll_interval.clamp( + config.daemon.min_poll_interval_sec, + config.daemon.max_poll_interval_sec, + ); + + log::debug!("Adaptive polling: set interval to {current_poll_interval}s"); + } else { + // If adaptive polling is disabled, still apply battery-saving adjustment + if config.daemon.throttle_on_battery && on_battery { + let battery_multiplier = 2; // poll half as often on battery + + // We need to make sure `poll_interval_sec` is *at least* 1 + // before multiplying. + let safe_interval = config.daemon.poll_interval_sec.max(1); + current_poll_interval = (safe_interval * battery_multiplier) + .min(config.daemon.max_poll_interval_sec); + + log::debug!( + "On battery power, increased poll interval to {current_poll_interval}s" + ); + } else { + // Use the configured poll interval + current_poll_interval = config.daemon.poll_interval_sec.max(1); + if config.daemon.poll_interval_sec == 0 { + log::debug!( + "Using minimum poll interval of 1s instead of configured 0s" + ); + } + } + } + } + Err(e) => { + log::error!("Error collecting system report: {e}"); + } + } + + // Sleep for the remaining time in the poll interval + let elapsed = start_time.elapsed(); + let poll_duration = Duration::from_secs(current_poll_interval); + if elapsed < poll_duration { + let sleep_time = poll_duration - elapsed; + log::debug!("Sleeping for {}s until next cycle", sleep_time.as_secs()); + std::thread::sleep(sleep_time); + } + } + + log::info!("Daemon stopped"); + Ok(()) +} + +/// Simplified system state used for determining when to adjust polling interval +#[derive(Debug, PartialEq, Eq, Clone, Hash, Default)] +enum SystemState { + #[default] + Unknown, + OnAC, + OnBattery, + HighLoad, + LowLoad, + HighTemp, + Idle, +} + +/// Determine the current system state for adaptive polling +fn determine_system_state(report: &SystemReport, history: &SystemHistory) -> SystemState { + // Check power state first + if !report.batteries.is_empty() { + if let Some(battery) = report.batteries.first() { + if battery.ac_connected { + return SystemState::OnAC; + } + return SystemState::OnBattery; + } + } + + // No batteries means desktop, so always AC + if report.batteries.is_empty() { + return SystemState::OnAC; + } + + // Check temperature + if let Some(temp) = report.cpu_global.average_temperature_celsius { + if temp > 80.0 { + return SystemState::HighTemp; + } + } + + // Check load first, as high load should take precedence over idle state + let avg_load = report.system_load.load_avg_1min; + if avg_load > 3.0 { + return SystemState::HighLoad; + } + + // Check idle state only if we don't have high load + if history.is_system_idle() { + return SystemState::Idle; + } + + // Check for low load + if avg_load < 0.5 { + return SystemState::LowLoad; + } + + // Default case + SystemState::Unknown +} diff --git a/src/main.rs b/src/main.rs index feed86a..e435cee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,52 +21,30 @@ use yansi::Paint as _; #[derive(clap::Parser, Debug)] #[clap(author, version, about)] struct Cli { + #[command(flatten)] + verbosity: clap_verbosity_flag::Verbosity, + #[clap(subcommand)] command: Command, } #[derive(clap::Parser, Debug)] -#[clap(multicall = true)] enum Command { - /// Watt daemon. - Watt { - #[command(flatten)] - verbosity: clap_verbosity_flag::Verbosity, + /// Display information. + Info, + /// Start the daemon. + Start { /// The daemon config path. #[arg(long, env = "WATT_CONFIG")] config: PathBuf, }, - /// CPU metadata and modification utility. - Cpu { - #[command(flatten)] - verbosity: clap_verbosity_flag::Verbosity, - - #[clap(subcommand)] - command: CpuCommand, - }, - - /// Power supply metadata and modification utility. - Power { - #[command(flatten)] - verbosity: clap_verbosity_flag::Verbosity, - - #[clap(subcommand)] - command: PowerCommand, - }, -} - -#[derive(clap::Parser, Debug)] -enum CpuCommand { /// Modify CPU attributes. - Set(config::CpuDelta), -} + CpuSet(config::CpuDelta), -#[derive(clap::Parser, Debug)] -enum PowerCommand { /// Modify power supply attributes. - Set(config::PowerDelta), + PowerSet(config::PowerDelta), } fn real_main() -> anyhow::Result<()> { @@ -74,33 +52,24 @@ fn real_main() -> anyhow::Result<()> { 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()) + .filter_level(cli.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")?; + Command::Info => todo!(), + + Command::Start { config } => { + let config = config::DaemonConfig::load_from(&config) + .context("failed to load daemon config file")?; daemon::run(config) } - Command::Cpu { - command: CpuCommand::Set(delta), - .. - } => delta.apply(), - - Command::Power { - command: PowerCommand::Set(delta), - .. - } => delta.apply(), + Command::CpuSet(delta) => delta.apply(), + Command::PowerSet(delta) => delta.apply(), } } diff --git a/src/monitor.rs b/src/monitor.rs index e4ff659..40b0242 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,3 +1,15 @@ +use crate::config::AppConfig; +use crate::core::{BatteryInfo, CpuCoreInfo, CpuGlobalInfo, SystemInfo, SystemLoad, SystemReport}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + str::FromStr, + thread, + time::Duration, + time::SystemTime, +}; + // 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") { @@ -22,3 +34,22 @@ // } // } // } + +pub fn get_cpu_model() -> anyhow::Result { + 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(), + )) +} diff --git a/src/system.rs b/src/system.rs index ed43ad7..4a86893 100644 --- a/src/system.rs +++ b/src/system.rs @@ -50,7 +50,6 @@ impl System { || self.is_desktop()?; self.rescan_load_average()?; - self.rescan_temperatures()?; Ok(()) }