diff --git a/Cargo.lock b/Cargo.lock index f077741..bb5a94f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,27 +182,6 @@ 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" @@ -232,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" @@ -301,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]] @@ -320,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" @@ -391,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" @@ -430,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" @@ -505,26 +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", - "clap-verbosity-flag", - "ctrlc", - "derive_more", - "dirs", - "env_logger", - "jiff", - "log", - "num_cpus", - "serde", - "thiserror", - "toml", - "yansi", -] - [[package]] name = "syn" version = "2.0.101" @@ -622,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" diff --git a/Cargo.toml b/Cargo.toml index aeecd4b..ecc84ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +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 "] +authors = ["NotAShelf ", "RGBCube "] rust-version = "1.85" [dependencies] -serde = { version = "1.0", features = ["derive"] } -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 = { version = "4.0", features = ["derive", "env"] } clap-verbosity-flag = "3.0.2" -yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] } +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"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..5cc203d --- /dev/null +++ b/build.rs @@ -0,0 +1,51 @@ +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 2f796b7..04159cd 100644 --- a/config.toml +++ b/config.toml @@ -1,2 +1,3 @@ [[rule]] priority = 0 +if = { value = "%cpu-usage", is-more-than = 0.7 } diff --git a/flake.nix b/flake.nix index b5af16a..59bc72c 100644 --- a/flake.nix +++ b/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); }; } diff --git a/src/config.rs b/src/config.rs index 2560806..b3d214e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -155,92 +155,151 @@ impl PowerDelta { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] -#[serde(untagged, rename_all = "kebab-case")] +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)] pub enum Expression { - #[serde(rename = "%cpu-usage")] + #[serde(with = "expression::cpu_usage")] CpuUsage, - #[serde(rename = "$cpu-usage-volatility")] + #[serde(with = "expression::cpu_usage_volatility")] CpuUsageVolatility, - #[serde(rename = "$cpu-temperature")] + #[serde(with = "expression::cpu_temperature")] CpuTemperature, - #[serde(rename = "$cpu-temperature-volatility")] + #[serde(with = "expression::cpu_temperature_volatility")] CpuTemperatureVolatility, - #[serde(rename = "$cpu-idle-seconds")] + #[serde(with = "expression::cpu_idle_seconds")] CpuIdleSeconds, - #[serde(rename = "%power-supply-charge")] + #[serde(with = "expression::power_supply_charge")] PowerSupplyCharge, - #[serde(rename = "%power-supply-discharge-rate")] + #[serde(with = "expression::power_supply_discharge_rate")] PowerSupplyDischargeRate, - #[serde(rename = "?charging")] - Charging, - #[serde(rename = "?on-battery")] - OnBattery, + #[serde(with = "expression::discharging")] + Discharging, - #[serde(rename = "#false")] - False, - - #[default] - #[serde(rename = "#true")] - True, + Boolean(bool), Number(f64), Plus { - value: Box, - plus: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "plus")] + b: Box, }, Minus { - value: Box, - minus: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "minus")] + b: Box, }, Multiply { - value: Box, - multiply: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "multiply")] + b: Box, }, Power { - value: Box, - power: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "power")] + b: Box, }, Divide { - value: Box, - divide: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "divide")] + b: Box, }, LessThan { - value: Box, - is_less_than: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "is-less-than")] + b: Box, }, - MoreThan { - value: Box, - is_more_than: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "is-more-than")] + b: Box, }, Equal { - value: Box, - is_equal: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "is-equal")] + b: Box, leeway: Box, }, And { - value: Box, - and: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "and")] + b: Box, }, All { all: Vec, }, Or { - value: Box, - or: Box, + #[serde(rename = "value")] + a: Box, + #[serde(rename = "or")] + b: Box, }, Any { any: Vec, @@ -251,25 +310,164 @@ 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 { - priority: u8, + pub priority: u8, #[serde(default, rename = "if", skip_serializing_if = "is_default")] - if_: Expression, + pub condition: Expression, #[serde(default, skip_serializing_if = "is_default")] - cpu: CpuDelta, + pub cpu: CpuDelta, #[serde(default, skip_serializing_if = "is_default")] - power: PowerDelta, + pub power: PowerDelta, } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(default, rename_all = "kebab-case")] pub struct DaemonConfig { #[serde(rename = "rule")] - rules: Vec, + pub rules: Vec, } impl DaemonConfig { @@ -278,7 +476,8 @@ impl DaemonConfig { format!("failed to read config from '{path}'", path = path.display()) })?; - let config: Self = toml::from_str(&contents).context("failed to parse config file")?; + let mut 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()); @@ -292,6 +491,10 @@ 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 deleted file mode 100644 index 2e32854..0000000 --- a/src/core.rs +++ /dev/null @@ -1,51 +0,0 @@ -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 7a60ee3..70918be 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1,13 +1,14 @@ use anyhow::{Context, bail}; use yansi::Paint as _; -use std::{cell::OnceCell, collections::HashMap, fmt, string::ToString}; +use std::{cell::OnceCell, collections::HashMap, fmt, mem, rc::Rc, string::ToString}; use crate::fs; #[derive(Default, Debug, Clone, PartialEq)] pub struct CpuRescanCache { stat: OnceCell>, + info: OnceCell>>>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -63,6 +64,7 @@ pub struct Cpu { pub epb: Option, pub stat: CpuStat, + pub info: Option>>, pub temperature: Option, } @@ -104,6 +106,7 @@ impl Cpu { softirq: 0, steal: 0, }, + info: None, temperature: None, }; @@ -171,6 +174,7 @@ impl Cpu { } self.rescan_stat(cache)?; + self.rescan_info(cache)?; Ok(()) } @@ -345,6 +349,57 @@ 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 f2d2e3a..9e0370a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,16 +1,18 @@ use std::{ - collections::VecDeque, - ops, + cell::LazyCell, + collections::{HashMap, VecDeque}, + ops::Deref, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, + thread, time::{Duration, Instant}, }; use anyhow::Context; -use crate::config; +use crate::{config, system}; /// Calculate the idle time multiplier based on system idle time. /// @@ -42,8 +44,8 @@ struct Daemon { /// The last computed polling interval. last_polling_interval: Option, - /// Whether if we are charging right now. - charging: bool, + /// The system state. + system: system::System, /// CPU usage and temperature log. cpu_log: VecDeque, @@ -52,6 +54,56 @@ 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, @@ -63,8 +115,6 @@ struct CpuLog { } struct CpuVolatility { - at: ops::Range, - usage: f64, temperature: f64, @@ -72,6 +122,17 @@ 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; } @@ -91,8 +152,6 @@ 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, }) @@ -134,6 +193,13 @@ 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. @@ -181,7 +247,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.charging { + if self.discharging() { match self.power_supply_discharge_rate() { Some(discharge_rate) => { if discharge_rate > 0.2 { @@ -243,6 +309,8 @@ 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)); @@ -254,7 +322,87 @@ pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { }) .context("failed to set Ctrl-C handler")?; - while !cancelled.load(Ordering::SeqCst) {} + 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); + } + } log::info!("exiting..."); diff --git a/src/daemon_old.rs b/src/daemon_old.rs deleted file mode 100644 index 3a20cb4..0000000 --- a/src/daemon_old.rs +++ /dev/null @@ -1,426 +0,0 @@ -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 e435cee..feed86a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,30 +21,52 @@ 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 { - /// Display information. - Info, + /// Watt daemon. + Watt { + #[command(flatten)] + verbosity: clap_verbosity_flag::Verbosity, - /// Start the daemon. - Start { /// The daemon config path. #[arg(long, env = "WATT_CONFIG")] config: PathBuf, }, - /// Modify CPU attributes. - CpuSet(config::CpuDelta), + /// 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), +} + +#[derive(clap::Parser, Debug)] +enum PowerCommand { /// Modify power supply attributes. - PowerSet(config::PowerDelta), + Set(config::PowerDelta), } fn real_main() -> anyhow::Result<()> { @@ -52,24 +74,33 @@ 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(cli.verbosity.log_level_filter()) + .filter_level(verbosity.log_level_filter()) .format_timestamp(None) .format_module_path(false) .init(); match cli.command { - Command::Info => todo!(), - - Command::Start { config } => { - let config = config::DaemonConfig::load_from(&config) - .context("failed to load daemon config file")?; + Command::Watt { config, .. } => { + let config = + config::DaemonConfig::load_from(&config).context("failed to load daemon config")?; daemon::run(config) } - Command::CpuSet(delta) => delta.apply(), - Command::PowerSet(delta) => delta.apply(), + Command::Cpu { + command: CpuCommand::Set(delta), + .. + } => delta.apply(), + + Command::Power { + command: PowerCommand::Set(delta), + .. + } => delta.apply(), } } diff --git a/src/monitor.rs b/src/monitor.rs index 40b0242..e4ff659 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,15 +1,3 @@ -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") { @@ -34,22 +22,3 @@ use std::{ // } // } // } - -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 4a86893..ed43ad7 100644 --- a/src/system.rs +++ b/src/system.rs @@ -50,6 +50,7 @@ impl System { || self.is_desktop()?; self.rescan_load_average()?; + self.rescan_temperatures()?; Ok(()) }