From 3cfbb99ebcae55be958bda13ab6c696edf172806 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 09:20:49 +0300 Subject: [PATCH 01/17] config: deprecate `%cpu-usage` Signed-off-by: NotAShelf Change-Id: Id8593744f5c1b4a862ff9d516570a1986a6a6964 --- Cargo.toml | 2 +- watt/config.rs | 8 ++++++- watt/config.toml | 58 +++++++++++++++++++++++++++++------------------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 73f113e..2e72210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,10 +20,10 @@ clap_complete = "4.5.59" clap_complete_nushell = "4.5.9" ctrlc = "3.5.1" env_logger = "0.11.8" +humantime = "2.3.0" log = "0.4.28" nix = { features = [ "fs" ], version = "0.31.1" } num_cpus = "1.17.0" -humantime = "2.3.0" serde = { features = [ "derive" ], version = "1.0.228" } toml = "0.9.8" yansi = { features = [ "detect-env", "detect-tty" ], version = "1.0.1" } diff --git a/watt/config.rs b/watt/config.rs index 28ef74d..bd5a991 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -746,7 +746,13 @@ impl Expression { FrequencyAvailable => Boolean(state.frequency_available), TurboAvailable => Boolean(state.turbo_available), - CpuUsage => Number(state.cpu_usage), + CpuUsage => { + bail!( + "`%cpu-usage` is deprecated and has been removed. Use \ + `cpu-usage-since = \"\"` instead. For example, \ + `cpu-usage-since = \"1sec\"` for CPU usage over the last second." + ) + }, CpuUsageSince { duration } => { let duration = humantime::parse_duration(duration) .with_context(|| format!("failed to parse duration '{duration}'"))?; diff --git a/watt/config.toml b/watt/config.toml index ee7e655..06f2f92 100644 --- a/watt/config.toml +++ b/watt/config.toml @@ -4,8 +4,8 @@ # Each rule can specify conditions and actions for CPU and power management. [[rule]] -name = "emergency-thermal-protection" if = { is-more-than = 85.0, value = "$cpu-temperature" } +name = "emergency-thermal-protection" priority = 100 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "power", then = "power" } @@ -15,8 +15,8 @@ cpu.governor = { if.is-governor-available = "powersave", th cpu.turbo = { if = "?turbo-available", then = false } [[rule]] -name = "critical-battery-preservation" if.all = [ "?discharging", { is-less-than = 0.3, value = "%power-supply-charge" } ] +name = "critical-battery-preservation" priority = 90 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "power", then = "power" } @@ -26,39 +26,51 @@ cpu.turbo = { if = "?turbo-available", then = false } power.platform-profile = { if.is-platform-profile-available = "low-power", then = "low-power" } [[rule]] -name = "high-load-performance-sustainance" if.all = [ - { is-more-than = 0.8, value = "%cpu-usage" }, + { is-more-than = 0.8, value = { cpu-usage-since = "2sec" } }, { is-less-than = 30.0, value = "$cpu-idle-seconds" }, { is-less-than = 75.0, value = "$cpu-temperature" }, ] +name = "high-load-performance-sustainance" priority = 80 -cpu.energy-perf-bias = { if.all = [{ not.is-driver-loaded = "intel_pstate" }, { is-energy-perf-bias-available = "performance" }], then = "performance" } -cpu.energy-performance-preference = { if.all = [{ not.is-driver-loaded = "intel_pstate" }, { is-energy-performance-preference-available = "performance" }], then = "performance" } -cpu.governor = { if.is-governor-available = "performance", then = "performance" } -cpu.turbo = { if = "?turbo-available", then = true } +cpu.energy-perf-bias = { if.all = [ + { not.is-driver-loaded = "intel_pstate" }, + { is-energy-perf-bias-available = "performance" }, +], then = "performance" } +cpu.energy-performance-preference = { if.all = [ + { not.is-driver-loaded = "intel_pstate" }, + { is-energy-performance-preference-available = "performance" }, +], then = "performance" } +cpu.governor = { if.is-governor-available = "performance", then = "performance" } +cpu.turbo = { if = "?turbo-available", then = true } [[rule]] -name = "plugged-in-performance" if.all = [ { not = "?discharging" }, - { is-more-than = 0.1, value = "%cpu-usage" }, + { is-more-than = 0.1, value = { cpu-usage-since = "1sec" } }, { is-less-than = 80.0, value = "$cpu-temperature" }, ] +name = "plugged-in-performance" priority = 70 -cpu.energy-perf-bias = { if.all = [{ not.is-driver-loaded = "intel_pstate" }, { is-energy-perf-bias-available = "balance-performance" }], then = "balance-performance" } -cpu.energy-performance-preference = { if.all = [{ not.is-driver-loaded = "intel_pstate" }, { is-energy-performance-preference-available = "performance" }], then = "performance" } -cpu.governor = { if.is-governor-available = "performance", then = "performance" } -cpu.turbo = { if = "?turbo-available", then = true } +cpu.energy-perf-bias = { if.all = [ + { not.is-driver-loaded = "intel_pstate" }, + { is-energy-perf-bias-available = "balance-performance" }, +], then = "balance-performance" } +cpu.energy-performance-preference = { if.all = [ + { not.is-driver-loaded = "intel_pstate" }, + { is-energy-performance-preference-available = "performance" }, +], then = "performance" } +cpu.governor = { if.is-governor-available = "performance", then = "performance" } +cpu.turbo = { if = "?turbo-available", then = true } [[rule]] -name = "moderate-load-balanced-performance" if.all = [ - { is-more-than = 0.4, value = "%cpu-usage" }, - { is-less-than = 0.8, value = "%cpu-usage" }, + { is-more-than = 0.4, value = { cpu-usage-since = "5sec" } }, + { is-less-than = 0.8, value = { cpu-usage-since = "5sec" } }, ] +name = "moderate-load-balanced-performance" priority = 60 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "balance-performance", then = "balance-performance" } @@ -66,11 +78,11 @@ cpu.energy-performance-preference = { if.is-energy-performance-preference-availa cpu.governor = { if.is-governor-available = "schedutil", then = "schedutil" } [[rule]] -name = "low-activity-power-saving" if.all = [ - { is-less-than = 0.2, value = "%cpu-usage" }, + { is-less-than = 0.2, value = { cpu-usage-since = "10sec" } }, { is-more-than = 60.0, value = "$cpu-idle-seconds" }, ] +name = "low-activity-power-saving" priority = 50 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "power", then = "power" } @@ -79,8 +91,8 @@ cpu.governor = { if.is-governor-available = "powersave", th cpu.turbo = { if = "?turbo-available", then = false } [[rule]] -name = "extended-idle-power-saving" if = { is-more-than = 300.0, value = "$cpu-idle-seconds" } +name = "extended-idle-power-saving" priority = 40 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "power", then = "power" } @@ -90,8 +102,8 @@ cpu.governor = { if.is-governor-available = "powersave", th cpu.turbo = { if = "?turbo-available", then = false } [[rule]] -name = "discharging-battery-conservation" if.all = [ "?discharging", { is-less-than = 0.5, value = "%power-supply-charge" } ] +name = "discharging-battery-conservation" priority = 30 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "power", then = "power" } @@ -102,8 +114,8 @@ cpu.turbo = { if = "?turbo-available", then = false } power.platform-profile = { if.is-platform-profile-available = "low-power", then = "low-power" } [[rule]] -name = "battery-balanced" if = "?discharging" +name = "battery-balanced" priority = 20 cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "balance-performance", then = "balance-performance" } @@ -114,8 +126,8 @@ cpu.governor = { if.is-governor-available = "powersave", th cpu.turbo = { if = "?turbo-available", then = false } [[rule]] -name = "default-balanced" cpu.energy-perf-bias = { if.is-energy-perf-bias-available = "balance-performance", then = "balance-performance" } cpu.energy-performance-preference = { if.is-energy-performance-preference-available = "balance_performance", then = "balance_performance" } cpu.governor = { if.is-governor-available = "schedutil", then = "schedutil" } +name = "default-balanced" priority = 0 From 0ada45970826c60cfb59eec3db3ce09185c9f84c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 09:21:38 +0300 Subject: [PATCH 02/17] docs: update references to `%cpu-usage` with `%cpu-usage-since` Signed-off-by: NotAShelf Change-Id: Ie65f50fae6ca27a115b12c3d04c7032f6a6a6964 --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2a517d9..80d8fdc 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ but Watt attempts to support multiple vendor implementations including: # High performance for sustained workloads with thermal protection [[rule]] if.all = [ - { is-more-than = 0.8, value = "%cpu-usage" }, + { is-more-than = 0.8, value = { cpu-usage-since = "2sec" } }, { is-less-than = 30.0, value = "$cpu-idle-seconds" }, { is-less-than = 75.0, value = "$cpu-temperature" }, ] @@ -262,7 +262,7 @@ Watt includes a powerful expression language for defining conditions: #### System Variables -- `"%cpu-usage"` - Current CPU usage percentage (0.0-1.0) +- `{ cpu-usage-since = "" }` - CPU usage percentage over a duration (e.g., `"1sec"`, `"5sec"`) - `"$cpu-usage-volatility"` - CPU usage volatility measurement - `"$cpu-temperature"` - CPU temperature in Celsius - `"$cpu-temperature-volatility"` - CPU temperature volatility @@ -305,7 +305,7 @@ You can use operators with TOML attribute sets: ```toml [[rule]] -if = { is-more-than = "%cpu-usage", value = 0.8 } +if = { is-more-than = { cpu-usage-since = "1sec" }, value = 0.8 } ``` However, `all` and `any` do not take a `value` argument, but instead take a list @@ -359,7 +359,7 @@ power.platform-profile = { if.is-platform-profile-available = "low-power", then # High performance mode for sustained high load [[rule]] if.all = [ - { is-more-than = 0.8, value = "%cpu-usage" }, + { is-more-than = 0.8, value = { cpu-usage-since = "2sec" } }, { is-less-than = 30.0, value = "$cpu-idle-seconds" }, { is-less-than = 75.0, value = "$cpu-temperature" }, ] @@ -374,7 +374,7 @@ cpu.turbo = { if = "?turbo-available", then = true } [[rule]] if.all = [ { not = "?discharging" }, - { is-more-than = 0.1, value = "%cpu-usage" }, + { is-more-than = 0.1, value = { cpu-usage-since = "1sec" } }, { is-less-than = 80.0, value = "$cpu-temperature" }, ] priority = 70 @@ -387,8 +387,8 @@ cpu.turbo = { if = "?turbo-available", then = true } # Moderate performance for medium load [[rule]] if.all = [ - { is-more-than = 0.4, value = "%cpu-usage" }, - { is-less-than = 0.8, value = "%cpu-usage" }, + { is-more-than = 0.4, value = { cpu-usage-since = "5sec" } }, + { is-less-than = 0.8, value = { cpu-usage-since = "5sec" } }, ] priority = 60 @@ -399,7 +399,7 @@ cpu.governor = { if.is-governor-available = "schedutil", then = "schedutil" } # Power saving during low activity [[rule]] if.all = [ - { is-less-than = 0.2, value = "%cpu-usage" }, + { is-less-than = 0.2, value = { cpu-usage-since = "10sec" } }, { is-more-than = 60.0, value = "$cpu-idle-seconds" }, ] priority = 50 From dd013c74bddb767642a28303698150409ca56bdc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 10:16:08 +0300 Subject: [PATCH 03/17] config: add `$cpu-scaling-maximum` and `$load-average-*` system variables Signed-off-by: NotAShelf Change-Id: Idf55162fa903329d659caac19bbe92e86a6a6964 --- watt/config.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ watt/system.rs | 12 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/watt/config.rs b/watt/config.rs index bd5a991..a2f6973 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -390,6 +390,12 @@ mod expression { named!(cpu_frequency_maximum => "$cpu-frequency-maximum"); named!(cpu_frequency_minimum => "$cpu-frequency-minimum"); + named!(cpu_scaling_maximum => "$cpu-scaling-maximum"); + + named!(load_average_1m => "$load-average-1m"); + named!(load_average_5m => "$load-average-5m"); + named!(load_average_15m => "$load-average-15m"); + named!(power_supply_charge => "%power-supply-charge"); named!(power_supply_discharge_rate => "%power-supply-discharge-rate"); @@ -453,6 +459,18 @@ pub enum Expression { #[serde(with = "expression::cpu_frequency_minimum")] CpuFrequencyMinimum, + #[serde(with = "expression::cpu_scaling_maximum")] + CpuScalingMaximum, + + #[serde(with = "expression::load_average_1m")] + LoadAverage1m, + + #[serde(with = "expression::load_average_5m")] + LoadAverage5m, + + #[serde(with = "expression::load_average_15m")] + LoadAverage15m, + #[serde(with = "expression::power_supply_charge")] PowerSupplyCharge, @@ -620,6 +638,12 @@ pub struct EvalState<'peripherals, 'context> { pub cpu_frequency_maximum: Option, pub cpu_frequency_minimum: Option, + pub cpu_scaling_maximum: Option, + + pub load_average_1m: f64, + pub load_average_5m: f64, + pub load_average_15m: f64, + pub power_supply_charge: Option, pub power_supply_discharge_rate: Option, @@ -783,6 +807,12 @@ impl Expression { CpuFrequencyMaximum => Number(try_ok!(state.cpu_frequency_maximum)), CpuFrequencyMinimum => Number(try_ok!(state.cpu_frequency_minimum)), + CpuScalingMaximum => Number(try_ok!(state.cpu_scaling_maximum)), + + LoadAverage1m => Number(state.load_average_1m), + LoadAverage5m => Number(state.load_average_5m), + LoadAverage15m => Number(state.load_average_15m), + PowerSupplyCharge => Number(try_ok!(state.power_supply_charge)), PowerSupplyDischargeRate => { Number(try_ok!(state.power_supply_discharge_rate)) @@ -1081,6 +1111,10 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(base_freq as f64), cpu_frequency_minimum: Some(1000.0), + cpu_scaling_maximum: Some(base_freq as f64), + load_average_1m: 0.5, + load_average_5m: 0.6, + load_average_15m: 0.7, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), discharging: false, @@ -1168,6 +1202,10 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), + cpu_scaling_maximum: Some(3500.0), + load_average_1m: 0.5, + load_average_5m: 0.6, + load_average_15m: 0.7, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), discharging: false, @@ -1243,6 +1281,10 @@ mod tests { cpu_idle_seconds: 0.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), + cpu_scaling_maximum: Some(3500.0), + load_average_1m: 0.0, + load_average_5m: 0.0, + load_average_15m: 0.0, power_supply_charge: None, power_supply_discharge_rate: None, discharging: false, diff --git a/watt/system.rs b/watt/system.rs index a50dd90..42f81f8 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -798,6 +798,18 @@ pub fn run_daemon(config: config::DaemonConfig) -> anyhow::Result<()> { .context("failed to read CPU hardware minimum frequency")? .map(|u64| u64 as f64), + cpu_scaling_maximum: system + .cpus + .iter() + .map(|cpu| cpu.frequency_mhz_maximum) + .max() + .flatten() + .map(|u64| u64 as f64), + + load_average_1m: system.load_average_1min, + load_average_5m: system.load_average_5min, + load_average_15m: system.load_average_15min, + power_supply_charge: system .power_supply_log .back() From b7d784de9d874910466a4678b1d213bad0bd9424 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 10:31:52 +0300 Subject: [PATCH 04/17] docs: mention new system variables Signed-off-by: NotAShelf Change-Id: I3d5a06963de7b1f485861ce77607d2226a6a6964 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 80d8fdc..5ab6a6c 100644 --- a/README.md +++ b/README.md @@ -262,13 +262,19 @@ Watt includes a powerful expression language for defining conditions: #### System Variables -- `{ cpu-usage-since = "" }` - CPU usage percentage over a duration (e.g., `"1sec"`, `"5sec"`) +- `{ cpu-usage-since = "" }` - CPU usage percentage over a duration + (e.g., `"1sec"`, `"5sec"`) - `"$cpu-usage-volatility"` - CPU usage volatility measurement - `"$cpu-temperature"` - CPU temperature in Celsius - `"$cpu-temperature-volatility"` - CPU temperature volatility - `"$cpu-idle-seconds"` - Seconds since last significant CPU activity - `"$cpu-frequency-maximum"` - CPU hardware maximum frequency in MHz - `"$cpu-frequency-minimum"` - CPU hardware minimum frequency in MHz +- `"$cpu-scaling-maximum"` - Current CPU scaling maximum frequency in MHz (from + `scaling_max_freq`) +- `"$load-average-1m"` - System load average over the last 1 minute +- `"$load-average-5m"` - System load average over the last 5 minutes +- `"$load-average-15m"` - System load average over the last 15 minutes - `"%power-supply-charge"` - Battery charge percentage (0.0-1.0) - `"%power-supply-discharge-rate"` - Current discharge rate - `"?discharging"` - Boolean indicating if system is on battery power From 70be1fad4226eee5021607de6a63dab181ec5715 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 10:43:37 +0300 Subject: [PATCH 05/17] config: add `$hour-of-day` variable for scripting Signed-off-by: NotAShelf Change-Id: Id94264310cd7f9ccde4a7d1edd162e336a6a6964 --- Cargo.lock | 354 +++++++++++++++++++++++++++++++++++++++--------- Cargo.toml | 1 + watt/Cargo.toml | 1 + watt/config.rs | 12 ++ 4 files changed, 301 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7b2e58..bdd1943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "autocfg" @@ -90,9 +90,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block2" @@ -117,9 +117,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.55" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -137,9 +137,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.55" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -149,9 +149,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ "clap", ] @@ -180,9 +180,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -192,12 +192,12 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.30.1", + "nix", "windows-sys", ] @@ -215,9 +215,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -264,6 +264,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" version = "0.3.4" @@ -276,6 +282,28 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -300,6 +328,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -307,7 +341,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -327,30 +363,59 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" dependencies = [ "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-sys", ] [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -371,21 +436,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "nix" @@ -447,15 +500,15 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -469,6 +522,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -480,9 +543,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", @@ -544,7 +607,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -558,9 +621,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -570,9 +633,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -581,9 +644,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rustix" @@ -610,6 +673,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -640,6 +709,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -657,9 +739,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -668,12 +750,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys", @@ -681,9 +763,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", @@ -705,9 +787,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -726,9 +808,15 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "utf8parse" @@ -754,6 +842,49 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "watt" version = "1.0.0" @@ -764,8 +895,9 @@ dependencies = [ "ctrlc", "env_logger", "humantime", + "jiff", "log", - "nix 0.31.1", + "nix", "num_cpus", "proptest", "serde", @@ -799,6 +931,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "xtask" @@ -821,20 +1035,26 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.35" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.35" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2e72210..7c04fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ clap_complete_nushell = "4.5.9" ctrlc = "3.5.1" env_logger = "0.11.8" humantime = "2.3.0" +jiff = "0.2.20" log = "0.4.28" nix = { features = [ "fs" ], version = "0.31.1" } num_cpus = "1.17.0" diff --git a/watt/Cargo.toml b/watt/Cargo.toml index 1fb9142..9666803 100644 --- a/watt/Cargo.toml +++ b/watt/Cargo.toml @@ -20,6 +20,7 @@ clap-verbosity-flag.workspace = true ctrlc.workspace = true env_logger.workspace = true humantime.workspace = true +jiff.workspace = true log.workspace = true nix.workspace = true num_cpus.workspace = true diff --git a/watt/config.rs b/watt/config.rs index a2f6973..1167a12 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -396,6 +396,8 @@ mod expression { named!(load_average_5m => "$load-average-5m"); named!(load_average_15m => "$load-average-15m"); + named!(hour_of_day => "$hour-of-day"); + named!(power_supply_charge => "%power-supply-charge"); named!(power_supply_discharge_rate => "%power-supply-discharge-rate"); @@ -471,6 +473,9 @@ pub enum Expression { #[serde(with = "expression::load_average_15m")] LoadAverage15m, + #[serde(with = "expression::hour_of_day")] + HourOfDay, + #[serde(with = "expression::power_supply_charge")] PowerSupplyCharge, @@ -813,6 +818,13 @@ impl Expression { LoadAverage5m => Number(state.load_average_5m), LoadAverage15m => Number(state.load_average_15m), + HourOfDay => { + let ts = jiff::Timestamp::now() + .in_tz("local") + .context("failed to get local timezone for `$hour-of-day`")?; + Number(ts.hour() as f64) + }, + PowerSupplyCharge => Number(try_ok!(state.power_supply_charge)), PowerSupplyDischargeRate => { Number(try_ok!(state.power_supply_discharge_rate)) From 0fea2d2774e2cdf3fbae7ae3e14f48395b1b0de2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 12:03:11 +0300 Subject: [PATCH 06/17] docs: add `$hour-of-day` to README Signed-off-by: NotAShelf Change-Id: Ia2809ab13363fa591132631f3956579a6a6a6964 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5ab6a6c..749fa58 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ Watt includes a powerful expression language for defining conditions: - `"$load-average-1m"` - System load average over the last 1 minute - `"$load-average-5m"` - System load average over the last 5 minutes - `"$load-average-15m"` - System load average over the last 15 minutes +- `"$hour-of-day"` - Current hour (0-23) based on system local time - `"%power-supply-charge"` - Battery charge percentage (0.0-1.0) - `"%power-supply-discharge-rate"` - Current discharge rate - `"?discharging"` - Boolean indicating if system is on battery power From f395cc87b188793236d3d287fbf2d4b796006be5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 12:23:45 +0300 Subject: [PATCH 07/17] config: add new variables Adds `%cpu-core-count`, `?lid-closed`, `$battery-cycles` and `%battery-health` variables. Signed-off-by: NotAShelf Change-Id: I513545b3c54aa7d725423896a295532a6a6a6964 --- watt/config.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/watt/config.rs b/watt/config.rs index 1167a12..49593e1 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -392,15 +392,22 @@ mod expression { named!(cpu_scaling_maximum => "$cpu-scaling-maximum"); + named!(cpu_core_count => "%cpu-core-count"); + named!(load_average_1m => "$load-average-1m"); named!(load_average_5m => "$load-average-5m"); named!(load_average_15m => "$load-average-15m"); + named!(lid_closed => "?lid-closed"); + named!(hour_of_day => "$hour-of-day"); named!(power_supply_charge => "%power-supply-charge"); named!(power_supply_discharge_rate => "%power-supply-discharge-rate"); + named!(battery_cycles => "$battery-cycles"); + named!(battery_health => "%battery-health"); + named!(discharging => "?discharging"); } @@ -464,6 +471,9 @@ pub enum Expression { #[serde(with = "expression::cpu_scaling_maximum")] CpuScalingMaximum, + #[serde(with = "expression::cpu_core_count")] + CpuCoreCount, + #[serde(with = "expression::load_average_1m")] LoadAverage1m, @@ -473,6 +483,9 @@ pub enum Expression { #[serde(with = "expression::load_average_15m")] LoadAverage15m, + #[serde(with = "expression::lid_closed")] + LidClosed, + #[serde(with = "expression::hour_of_day")] HourOfDay, @@ -482,6 +495,12 @@ pub enum Expression { #[serde(with = "expression::power_supply_discharge_rate")] PowerSupplyDischargeRate, + #[serde(with = "expression::battery_cycles")] + BatteryCycles, + + #[serde(with = "expression::battery_health")] + BatteryHealth, + #[serde(with = "expression::discharging")] Discharging, @@ -645,13 +664,20 @@ pub struct EvalState<'peripherals, 'context> { pub cpu_scaling_maximum: Option, + pub cpu_core_count: u32, + pub load_average_1m: f64, pub load_average_5m: f64, pub load_average_15m: f64, + pub lid_closed: bool, + pub power_supply_charge: Option, pub power_supply_discharge_rate: Option, + pub battery_cycles: Option, + pub battery_health: Option, + pub discharging: bool, pub context: EvalContext<'context>, @@ -814,10 +840,14 @@ impl Expression { CpuScalingMaximum => Number(try_ok!(state.cpu_scaling_maximum)), + CpuCoreCount => Number(state.cpu_core_count as f64), + LoadAverage1m => Number(state.load_average_1m), LoadAverage5m => Number(state.load_average_5m), LoadAverage15m => Number(state.load_average_15m), + LidClosed => Boolean(state.lid_closed), + HourOfDay => { let ts = jiff::Timestamp::now() .in_tz("local") @@ -830,6 +860,9 @@ impl Expression { Number(try_ok!(state.power_supply_discharge_rate)) }, + BatteryCycles => Number(try_ok!(state.battery_cycles)), + BatteryHealth => Number(try_ok!(state.battery_health)), + Discharging => Boolean(state.discharging), literal @ (Boolean(_) | Number(_) | String(_)) => literal.clone(), @@ -1124,11 +1157,15 @@ mod tests { cpu_frequency_maximum: Some(base_freq as f64), cpu_frequency_minimum: Some(1000.0), cpu_scaling_maximum: Some(base_freq as f64), + cpu_core_count: 1, load_average_1m: 0.5, load_average_5m: 0.6, load_average_15m: 0.7, + lid_closed: false, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), + battery_cycles: Some(100.0), + battery_health: Some(0.95), discharging: false, context: EvalContext::Cpu(&cpu), cpus: &cpus, @@ -1215,11 +1252,15 @@ mod tests { cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), cpu_scaling_maximum: Some(3500.0), + cpu_core_count: 1, load_average_1m: 0.5, load_average_5m: 0.6, load_average_15m: 0.7, + lid_closed: false, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), + battery_cycles: Some(100.0), + battery_health: Some(0.95), discharging: false, context: EvalContext::Cpu(&cpu), cpus: &cpus, @@ -1294,11 +1335,15 @@ mod tests { cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), cpu_scaling_maximum: Some(3500.0), + cpu_core_count: 1, load_average_1m: 0.0, load_average_5m: 0.0, load_average_15m: 0.0, + lid_closed: false, power_supply_charge: None, power_supply_discharge_rate: None, + battery_cycles: None, + battery_health: None, discharging: false, context: EvalContext::Cpu(&cpu), cpus: &cpus, From 1bc639d145f08ac104a379888483c3c32214b345 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 13:04:51 +0300 Subject: [PATCH 08/17] power_supply: scan battery cycle count and health from sysfs Signed-off-by: NotAShelf Change-Id: Ic792e0ccffd71f3229c03f91572c72006a6a6964 --- watt/power_supply.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/watt/power_supply.rs b/watt/power_supply.rs index bb9bf13..6e7db3d 100644 --- a/watt/power_supply.rs +++ b/watt/power_supply.rs @@ -60,6 +60,9 @@ pub struct PowerSupply { pub charge_state: Option, pub charge_percent: Option, + pub cycle_count: Option, + pub health: Option, + pub charge_threshold_start: f64, pub charge_threshold_end: f64, @@ -155,6 +158,9 @@ impl PowerSupply { charge_state: None, charge_percent: None, + cycle_count: None, + health: None, + charge_threshold_start: 0.0, charge_threshold_end: 1.0, @@ -251,6 +257,35 @@ impl PowerSupply { .with_context(|| format!("failed to read {self} charge percent"))? .map(|percent| percent as f64 / 100.0); + self.cycle_count = fs::read_n::(self.path.join("cycle_count")) + .with_context(|| format!("failed to read {self} cycle count"))?; + + // Battery health as a percentage (0-100) + // Some systems report this as capacity_level or health + self.health = if let Some(health) = + fs::read_n::(self.path.join("health")) + .with_context(|| format!("failed to read {self} health"))? + { + Some(health as f64 / 100.0) + } else { + // Try to calculate health from energy_full vs energy_full_design + let energy_full = fs::read_n::(self.path.join("energy_full")) + .with_context(|| format!("failed to read {self} energy_full"))?; + + let energy_full_design = + fs::read_n::(self.path.join("energy_full_design")) + .with_context(|| { + format!("failed to read {self} energy_full_design") + })?; + + match (energy_full, energy_full_design) { + (Some(full), Some(design)) if design > 0 => { + Some(full as f64 / design as f64) + }, + _ => None, + } + }; + self.charge_threshold_start = fs::read_n::(self.path.join("charge_control_start_threshold")) .with_context(|| { From 5152a61b0ceeea9c5047f43c82fc34b78aa11755 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 13:08:30 +0300 Subject: [PATCH 09/17] system: add lid state scanning & battery data aggregation Signed-off-by: NotAShelf Change-Id: Ifd02e5852dcfff8720ba3f631c47d00d6a6a6964 --- watt/system.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/watt/system.rs b/watt/system.rs index 42f81f8..c6b61e3 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -62,6 +62,8 @@ struct PowerSupplyLog { struct System { is_ac: bool, + lid_closed: bool, + load_average_1min: f64, load_average_5min: f64, load_average_15min: f64, @@ -76,6 +78,11 @@ struct System { power_supplies: HashSet>, /// Power supply status log. power_supply_log: VecDeque, + + /// Battery cycle count (aggregated average across all batteries). + battery_cycles: Option, + /// Battery health (aggregated average across all batteries). + battery_health: Option, } impl System { @@ -158,6 +165,15 @@ impl System { ); } + { + let start = Instant::now(); + self.scan_lid_state()?; + log::info!( + "scanned lid state in {millis}ms", + millis = start.elapsed().as_millis(), + ); + } + { let start = Instant::now(); self.scan_temperatures()?; @@ -215,6 +231,59 @@ impl System { self.power_supply_log.push_back(power_supply_log); } + // Aggregate battery cycle count and health + if !self.power_supplies.is_empty() { + let batteries: Vec<_> = self + .power_supplies + .iter() + .filter(|ps| ps.type_ == "Battery" && !ps.is_from_peripheral) + .collect(); + + if !batteries.is_empty() { + // Calculate average cycle count across all batteries + let (cycle_sum, cycle_count) = + batteries + .iter() + .fold((0u64, 0u32), |(sum, count), power_supply| { + if let Some(cycles) = power_supply.cycle_count { + (sum + cycles, count + 1) + } else { + (sum, count) + } + }); + + self.battery_cycles = if cycle_count > 0 { + Some(cycle_sum as f64 / cycle_count as f64) + } else { + None + }; + + // Calculate average health across all batteries + let (health_sum, health_count) = + batteries + .iter() + .fold((0.0, 0u32), |(sum, count), power_supply| { + if let Some(health) = power_supply.health { + (sum + health, count + 1) + } else { + (sum, count) + } + }); + + self.battery_health = if health_count > 0 { + Some(health_sum / health_count as f64) + } else { + None + }; + } else { + self.battery_cycles = None; + self.battery_health = None; + } + } else { + self.battery_cycles = None; + self.battery_health = None; + } + Ok(()) } @@ -461,6 +530,105 @@ impl System { Ok(()) } + // Scan and identify the current lid state. + // XXX: Most "uniform" APIs for identifying this data rely on some abstraction + // library that *might or might not be installed*. The verbose fallback is, + // unfortunately, necessary as there is no guarantee that we can use those + // APIs. + fn scan_lid_state(&mut self) -> anyhow::Result<()> { + log::trace!("scanning lid state"); + + // Try ACPI button interface first + let acpi_lid_paths = [ + "/proc/acpi/button/lid/LID/state", // most likely to exist + "/proc/acpi/button/lid/LID0/state", + "/proc/acpi/button/lid/LID1/state", + ]; + + for path in acpi_lid_paths { + if let Some(content) = + fs::read(path).context("failed to read lid state from ACPI")? + { + // Content is typically "state: open" or "state: closed" + self.lid_closed = content.contains("closed"); + log::debug!("lid state from {path}: {content}"); + return Ok(()); + } + } + + // Try sysfs input device interface as fallback + const INPUT_PATH: &str = "/sys/class/input"; + + if let Some(input_entries) = + fs::read_dir(INPUT_PATH).context("failed to read input device entries")? + { + for entry in input_entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + log::debug!("failed to read input device entry: {error}"); + continue; + }, + }; + + let entry_path = entry.path(); + let device_name_path = entry_path.join("device/name"); + + let Some(name) = fs::read(&device_name_path).with_context(|| { + format!( + "failed to read input device name from '{path}'", + path = device_name_path.display(), + ) + })? + else { + continue; + }; + + // Look for lid switch input device + if name.trim() == "Lid Switch" { + // Read the lid switch state from the SW_LID capability + let state_path = entry_path.join("device/capabilities/sw"); + + let Some(sw_caps) = fs::read(&state_path).with_context(|| { + format!( + "failed to read switch capabilities from '{path}'", + path = state_path.display(), + ) + })? + else { + log::debug!( + "found lid switch at {path} but no switch capabilities file", + path = entry_path.display() + ); + continue; + }; + + // SW_LID is bit 0 in the capabilities bitmask + // The state file shows the current state of switches as a hex bitmask + // If bit 0 is set, the lid is closed + if let Ok(caps) = u64::from_str_radix(sw_caps.trim(), 16) { + self.lid_closed = (caps & 0x1) != 0; + log::debug!( + "lid state from input device {path}: {state}", + path = entry_path.display(), + state = if self.lid_closed { "closed" } else { "open" } + ); + return Ok(()); + } + } + } + } + + // If we reach here, this is likely a desktop or the lid state is not + // available Default to lid open (false) + log::debug!( + "no lid switch found, assuming desktop or lid state unavailable" + ); + self.lid_closed = false; + + Ok(()) + } + fn is_desktop(&mut self) -> anyhow::Result { log::debug!("checking chassis type to determine if system is a desktop"); if let Some(chassis_type) = fs::read("/sys/class/dmi/id/chassis_type") @@ -512,7 +680,7 @@ impl System { if !power_saving_exists { log::debug!("power saving paths do not exist, short circuting true"); - return Ok(true); // Likely a desktop. + return Ok(true); // likely a desktop. } // Default to assuming desktop if we can't determine. @@ -806,16 +974,23 @@ pub fn run_daemon(config: config::DaemonConfig) -> anyhow::Result<()> { .flatten() .map(|u64| u64 as f64), + cpu_core_count: system.cpus.len() as u32, + load_average_1m: system.load_average_1min, load_average_5m: system.load_average_5min, load_average_15m: system.load_average_15min, + lid_closed: system.lid_closed, + power_supply_charge: system .power_supply_log .back() .map(|log| log.charge), power_supply_discharge_rate: system.power_supply_discharge_rate(), + battery_cycles: system.battery_cycles, + battery_health: system.battery_health, + discharging: system.is_discharging(), context: config::EvalContext::WidestPossible, From b9056fe4469d7c4de3db20d1c0d3b68a4a784355 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 15:06:33 +0300 Subject: [PATCH 10/17] various: `cycle_count` -> `cycles` Signed-off-by: NotAShelf Change-Id: Ie8f136a922eebace444a8b40cebd7eac6a6a6964 --- watt/power_supply.rs | 8 ++++---- watt/system.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/watt/power_supply.rs b/watt/power_supply.rs index 6e7db3d..70ebd11 100644 --- a/watt/power_supply.rs +++ b/watt/power_supply.rs @@ -60,8 +60,8 @@ pub struct PowerSupply { pub charge_state: Option, pub charge_percent: Option, - pub cycle_count: Option, - pub health: Option, + pub cycles: Option, + pub health: Option, pub charge_threshold_start: f64, pub charge_threshold_end: f64, @@ -158,7 +158,7 @@ impl PowerSupply { charge_state: None, charge_percent: None, - cycle_count: None, + cycles: None, health: None, charge_threshold_start: 0.0, @@ -257,7 +257,7 @@ impl PowerSupply { .with_context(|| format!("failed to read {self} charge percent"))? .map(|percent| percent as f64 / 100.0); - self.cycle_count = fs::read_n::(self.path.join("cycle_count")) + self.cycles = fs::read_n::(self.path.join("cycles")) .with_context(|| format!("failed to read {self} cycle count"))?; // Battery health as a percentage (0-100) diff --git a/watt/system.rs b/watt/system.rs index c6b61e3..82fb90c 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -241,19 +241,19 @@ impl System { if !batteries.is_empty() { // Calculate average cycle count across all batteries - let (cycle_sum, cycle_count) = + let (cycle_sum, cycles) = batteries .iter() .fold((0u64, 0u32), |(sum, count), power_supply| { - if let Some(cycles) = power_supply.cycle_count { + if let Some(cycles) = power_supply.cycles { (sum + cycles, count + 1) } else { (sum, count) } }); - self.battery_cycles = if cycle_count > 0 { - Some(cycle_sum as f64 / cycle_count as f64) + self.battery_cycles = if cycles > 0 { + Some(cycle_sum as f64 / cycles as f64) } else { None }; From 23429e761200b8d36f674c1c56853a8ee7783655 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 15:14:49 +0300 Subject: [PATCH 11/17] system: flip battery aggregation ifs; put failure cases first Signed-off-by: NotAShelf Change-Id: If2d8e55a400a7a52903752fb480728056a6a6964 --- watt/system.rs | 85 ++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/watt/system.rs b/watt/system.rs index 82fb90c..51e45c7 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -232,56 +232,51 @@ impl System { } // Aggregate battery cycle count and health - if !self.power_supplies.is_empty() { - let batteries: Vec<_> = self - .power_supplies - .iter() - .filter(|ps| ps.type_ == "Battery" && !ps.is_from_peripheral) - .collect(); + let batteries: Vec<_> = self + .power_supplies + .iter() + .filter(|ps| ps.type_ == "Battery" && !ps.is_from_peripheral) + .collect(); - if !batteries.is_empty() { - // Calculate average cycle count across all batteries - let (cycle_sum, cycles) = - batteries - .iter() - .fold((0u64, 0u32), |(sum, count), power_supply| { - if let Some(cycles) = power_supply.cycles { - (sum + cycles, count + 1) - } else { - (sum, count) - } - }); + if self.power_supplies.is_empty() || batteries.is_empty() { + self.battery_cycles = None; + self.battery_health = None; + } else { + // Calculate average cycle count across all batteries + let (cycle_sum, cycles) = + batteries + .iter() + .fold((0u64, 0u32), |(sum, count), power_supply| { + if let Some(cycles) = power_supply.cycles { + (sum + cycles, count + 1) + } else { + (sum, count) + } + }); - self.battery_cycles = if cycles > 0 { - Some(cycle_sum as f64 / cycles as f64) - } else { - None - }; + self.battery_cycles = if cycles > 0 { + Some(cycle_sum as f64 / cycles as f64) + } else { + None + }; - // Calculate average health across all batteries - let (health_sum, health_count) = - batteries - .iter() - .fold((0.0, 0u32), |(sum, count), power_supply| { - if let Some(health) = power_supply.health { - (sum + health, count + 1) - } else { - (sum, count) - } - }); + // Calculate average health across all batteries + let (health_sum, health_count) = + batteries + .iter() + .fold((0.0, 0u32), |(sum, count), power_supply| { + if let Some(health) = power_supply.health { + (sum + health, count + 1) + } else { + (sum, count) + } + }); - self.battery_health = if health_count > 0 { - Some(health_sum / health_count as f64) - } else { - None - }; + self.battery_health = if health_count > 0 { + Some(health_sum / health_count as f64) } else { - self.battery_cycles = None; - self.battery_health = None; - } - } else { - self.battery_cycles = None; - self.battery_health = None; + None + }; } Ok(()) From 9ace1eff24eb0f21bbdfbd7ebc316ea691dbf45a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 15:45:13 +0300 Subject: [PATCH 12/17] config: derive `cpu-scaling-maximum` and `cpu-core-count` from cpus Now computed on-demand from `state.cpus`` during expression evaluation rather than stored as pre-computed fields in `EvalState`. Signed-off-by: NotAShelf Change-Id: Ifeb78acbb2f8c28468b6bcc912f88b556a6a6964 --- watt/config.rs | 22 ++++++++++------------ watt/system.rs | 10 ---------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/watt/config.rs b/watt/config.rs index 49593e1..6587df0 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -662,10 +662,6 @@ pub struct EvalState<'peripherals, 'context> { pub cpu_frequency_maximum: Option, pub cpu_frequency_minimum: Option, - pub cpu_scaling_maximum: Option, - - pub cpu_core_count: u32, - pub load_average_1m: f64, pub load_average_5m: f64, pub load_average_15m: f64, @@ -838,9 +834,17 @@ impl Expression { CpuFrequencyMaximum => Number(try_ok!(state.cpu_frequency_maximum)), CpuFrequencyMinimum => Number(try_ok!(state.cpu_frequency_minimum)), - CpuScalingMaximum => Number(try_ok!(state.cpu_scaling_maximum)), + CpuScalingMaximum => { + let max = state + .cpus + .iter() + .filter_map(|cpu| cpu.frequency_mhz_maximum) + .max() + .map(|v| v as f64); + Number(try_ok!(max)) + }, - CpuCoreCount => Number(state.cpu_core_count as f64), + CpuCoreCount => Number(state.cpus.len() as f64), LoadAverage1m => Number(state.load_average_1m), LoadAverage5m => Number(state.load_average_5m), @@ -1156,8 +1160,6 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(base_freq as f64), cpu_frequency_minimum: Some(1000.0), - cpu_scaling_maximum: Some(base_freq as f64), - cpu_core_count: 1, load_average_1m: 0.5, load_average_5m: 0.6, load_average_15m: 0.7, @@ -1251,8 +1253,6 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), - cpu_scaling_maximum: Some(3500.0), - cpu_core_count: 1, load_average_1m: 0.5, load_average_5m: 0.6, load_average_15m: 0.7, @@ -1334,8 +1334,6 @@ mod tests { cpu_idle_seconds: 0.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), - cpu_scaling_maximum: Some(3500.0), - cpu_core_count: 1, load_average_1m: 0.0, load_average_5m: 0.0, load_average_15m: 0.0, diff --git a/watt/system.rs b/watt/system.rs index 51e45c7..fcee259 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -961,16 +961,6 @@ pub fn run_daemon(config: config::DaemonConfig) -> anyhow::Result<()> { .context("failed to read CPU hardware minimum frequency")? .map(|u64| u64 as f64), - cpu_scaling_maximum: system - .cpus - .iter() - .map(|cpu| cpu.frequency_mhz_maximum) - .max() - .flatten() - .map(|u64| u64 as f64), - - cpu_core_count: system.cpus.len() as u32, - load_average_1m: system.load_average_1min, load_average_5m: system.load_average_5min, load_average_15m: system.load_average_15min, From 2aa8908cb41774b2b6602c8b43526cb4b2029042 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 16:06:24 +0300 Subject: [PATCH 13/17] config: replace static load-average variables with load-average-since I guess this was bound to happen and now I feel stupid for wasting my time with the static expressions earlier. This commit adds `load-average-since` that computers average load from the CPU log over a configurable duration, similiar to `cpu-usage-since`. Signed-off-by: NotAShelf Change-Id: I95a9e5dca7a34da5395ade1d6deb25a76a6a6964 --- watt/config.rs | 51 +++++++++++++++++++++++--------------------------- watt/system.rs | 9 +++++---- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/watt/config.rs b/watt/config.rs index 6587df0..441595c 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -394,10 +394,6 @@ mod expression { named!(cpu_core_count => "%cpu-core-count"); - named!(load_average_1m => "$load-average-1m"); - named!(load_average_5m => "$load-average-5m"); - named!(load_average_15m => "$load-average-15m"); - named!(lid_closed => "?lid-closed"); named!(hour_of_day => "$hour-of-day"); @@ -474,14 +470,10 @@ pub enum Expression { #[serde(with = "expression::cpu_core_count")] CpuCoreCount, - #[serde(with = "expression::load_average_1m")] - LoadAverage1m, - - #[serde(with = "expression::load_average_5m")] - LoadAverage5m, - - #[serde(with = "expression::load_average_15m")] - LoadAverage15m, + #[serde(rename = "load-average-since")] + LoadAverageSince { + duration: String, + }, #[serde(with = "expression::lid_closed")] LidClosed, @@ -662,10 +654,6 @@ pub struct EvalState<'peripherals, 'context> { pub cpu_frequency_maximum: Option, pub cpu_frequency_minimum: Option, - pub load_average_1m: f64, - pub load_average_5m: f64, - pub load_average_15m: f64, - pub lid_closed: bool, pub power_supply_charge: Option, @@ -846,9 +834,25 @@ impl Expression { CpuCoreCount => Number(state.cpus.len() as f64), - LoadAverage1m => Number(state.load_average_1m), - LoadAverage5m => Number(state.load_average_5m), - LoadAverage15m => Number(state.load_average_15m), + LoadAverageSince { duration } => { + let duration = humantime::parse_duration(duration) + .with_context(|| format!("failed to parse duration '{duration}'"))?; + let recent_logs: Vec<&system::CpuLog> = state + .cpu_log + .iter() + .rev() + .take_while(|log| log.at.elapsed() < duration) + .collect(); + + if recent_logs.len() < 2 { + return Ok(None); + } + + Number( + recent_logs.iter().map(|log| log.load_average).sum::() + / recent_logs.len() as f64, + ) + }, LidClosed => Boolean(state.lid_closed), @@ -1160,9 +1164,6 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(base_freq as f64), cpu_frequency_minimum: Some(1000.0), - load_average_1m: 0.5, - load_average_5m: 0.6, - load_average_15m: 0.7, lid_closed: false, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), @@ -1253,9 +1254,6 @@ mod tests { cpu_idle_seconds: 10.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), - load_average_1m: 0.5, - load_average_5m: 0.6, - load_average_15m: 0.7, lid_closed: false, power_supply_charge: Some(0.8), power_supply_discharge_rate: Some(10.0), @@ -1334,9 +1332,6 @@ mod tests { cpu_idle_seconds: 0.0, cpu_frequency_maximum: Some(3333.0), cpu_frequency_minimum: Some(1000.0), - load_average_1m: 0.0, - load_average_5m: 0.0, - load_average_15m: 0.0, lid_closed: false, power_supply_charge: None, power_supply_discharge_rate: None, diff --git a/watt/system.rs b/watt/system.rs index fcee259..429ba49 100644 --- a/watt/system.rs +++ b/watt/system.rs @@ -41,6 +41,9 @@ pub struct CpuLog { /// CPU temperature in celsius. pub temperature: f64, + + /// Load average. + pub load_average: f64, } #[derive(Debug)] @@ -200,6 +203,8 @@ impl System { temperature: self.cpu_temperatures.values().sum::() / self.cpu_temperatures.len() as f64, + + load_average: self.load_average_1min, }; log::debug!("appending CPU log item: {cpu_log:?}"); self.cpu_log.push_back(cpu_log); @@ -961,10 +966,6 @@ pub fn run_daemon(config: config::DaemonConfig) -> anyhow::Result<()> { .context("failed to read CPU hardware minimum frequency")? .map(|u64| u64 as f64), - load_average_1m: system.load_average_1min, - load_average_5m: system.load_average_5min, - load_average_15m: system.load_average_15min, - lid_closed: system.lid_closed, power_supply_charge: system From fd2714a71f082eeae2b8fc9af4b9fd7e02839e68 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 16:32:48 +0300 Subject: [PATCH 14/17] power_supply: fix sysfs path for `cycle_count` I've changed cycles to cycle paths via search & replace previously so it accidentally regressed the path. This fixes the incorrect sysfs path from "cycles" to "cycle_count" for reading battery cycle count on Linux systems. Signed-off-by: NotAShelf Change-Id: Ia801fe84abb87cbfbf5e14be14eeab566a6a6964 --- watt/power_supply.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watt/power_supply.rs b/watt/power_supply.rs index 70ebd11..7c37e03 100644 --- a/watt/power_supply.rs +++ b/watt/power_supply.rs @@ -257,7 +257,7 @@ impl PowerSupply { .with_context(|| format!("failed to read {self} charge percent"))? .map(|percent| percent as f64 / 100.0); - self.cycles = fs::read_n::(self.path.join("cycles")) + self.cycles = fs::read_n::(self.path.join("cycle_count")) .with_context(|| format!("failed to read {self} cycle count"))?; // Battery health as a percentage (0-100) From 2efb67ca23c4cbf208b3c776691567d4f685700d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 16:38:23 +0300 Subject: [PATCH 15/17] config: fix serde rename placement on `LoadAverageSince` Signed-off-by: NotAShelf Change-Id: I99b2146dfff44569fd90436a836692476a6a6964 --- watt/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watt/config.rs b/watt/config.rs index 441595c..cc0136f 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -470,8 +470,8 @@ pub enum Expression { #[serde(with = "expression::cpu_core_count")] CpuCoreCount, - #[serde(rename = "load-average-since")] LoadAverageSince { + #[serde(rename = "load-average-since")] duration: String, }, From 78a1108daef9255ce54f65963de6619851b91615 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 16:56:14 +0300 Subject: [PATCH 16/17] config: add per-battery expressions and predicate Adds new expression variants: - `BatteryCyclesFor { name }` - cycle count for specific battery - `BatteryHealthFor { name }` - health for specific battery - `IsBatteryAvailable { value }` - predicate to check battery existence Those eplace the aggregated-only `battery_cycles` and `battery_health`. Signed-off-by: NotAShelf Change-Id: I736448f3e8a73a6a90538cd99af188996a6a6964 --- watt/config.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/watt/config.rs b/watt/config.rs index cc0136f..6961f81 100644 --- a/watt/config.rs +++ b/watt/config.rs @@ -35,6 +35,16 @@ fn is_default(value: &T) -> bool { *value == T::default() } +fn find_battery<'a>( + power_supplies: &'a HashSet>, + name: &str, +) -> Option<&'a power_supply::PowerSupply> { + power_supplies + .iter() + .find(|ps| ps.name == name && ps.type_ == "Battery") + .map(|arc| arc.as_ref()) +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(deny_unknown_fields, default, rename_all = "kebab-case")] pub struct CpusDelta { @@ -432,6 +442,11 @@ pub enum Expression { value: Box, }, + IsBatteryAvailable { + #[serde(rename = "is-battery-available")] + value: Box, + }, + #[serde(with = "expression::frequency_available")] FrequencyAvailable, @@ -493,6 +508,16 @@ pub enum Expression { #[serde(with = "expression::battery_health")] BatteryHealth, + BatteryCyclesFor { + #[serde(rename = "battery-cycles-for")] + name: String, + }, + + BatteryHealthFor { + #[serde(rename = "battery-health-for")] + name: String, + }, + #[serde(with = "expression::discharging")] Discharging, @@ -782,6 +807,11 @@ impl Expression { Boolean(crate::fs::exists(format!("/sys/module/{value}"))) }, + IsBatteryAvailable { value } => { + let value = eval!(value).try_into_string()?; + + Boolean(find_battery(&state.power_supplies, &value).is_some()) + }, FrequencyAvailable => Boolean(state.frequency_available), TurboAvailable => Boolean(state.turbo_available), @@ -871,6 +901,16 @@ impl Expression { BatteryCycles => Number(try_ok!(state.battery_cycles)), BatteryHealth => Number(try_ok!(state.battery_health)), + BatteryCyclesFor { name } => { + let battery = find_battery(&state.power_supplies, name); + Number(try_ok!(battery.and_then(|ps| ps.cycles).map(|c| c as f64))) + }, + + BatteryHealthFor { name } => { + let battery = find_battery(&state.power_supplies, name); + Number(try_ok!(battery.and_then(|ps| ps.health))) + }, + Discharging => Boolean(state.discharging), literal @ (Boolean(_) | Number(_) | String(_)) => literal.clone(), From 340eed0d3e65c64349c51cb348cd30faa2264263 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 17 Feb 2026 17:42:54 +0300 Subject: [PATCH 17/17] docs: document new DSL expressions and variables Signed-off-by: NotAShelf Change-Id: Ibaeb1235a15aebe0450f6f056c6a9c966a6a6964 --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 749fa58..b7a7fe7 100644 --- a/README.md +++ b/README.md @@ -272,12 +272,20 @@ Watt includes a powerful expression language for defining conditions: - `"$cpu-frequency-minimum"` - CPU hardware minimum frequency in MHz - `"$cpu-scaling-maximum"` - Current CPU scaling maximum frequency in MHz (from `scaling_max_freq`) -- `"$load-average-1m"` - System load average over the last 1 minute -- `"$load-average-5m"` - System load average over the last 5 minutes -- `"$load-average-15m"` - System load average over the last 15 minutes +- `"%cpu-core-count"` - Number of CPU cores +- `{ load-average-since = "" }` - System load average over a duration + (e.g., `"5sec"`, `"1min"`) - `"$hour-of-day"` - Current hour (0-23) based on system local time +- `"?lid-closed"` - Boolean indicating if laptop lid is closed - `"%power-supply-charge"` - Battery charge percentage (0.0-1.0) - `"%power-supply-discharge-rate"` - Current discharge rate +- `"%battery-cycles"` - Battery cycle count (aggregated average across all + batteries) +- `"%battery-health"` - Battery health percentage (0.0-1.0, aggregated average + across all batteries) +- `{ battery-cycles-for = "" }` - Battery cycle count for a specific + battery (e.g., `"BAT0"`) +- `{ battery-health-for = "" }` - Battery health for a specific battery - `"?discharging"` - Boolean indicating if system is on battery power - `"?frequency-available"` - Boolean indicating if CPU frequency control is available @@ -295,6 +303,7 @@ if.is-energy-performance-preference-available = "balance_performance" if.is-energy-perf-bias-available = "5" if.is-platform-profile-available = "low-power" if.is-driver-loaded = "intel_pstate" +if.is-battery-available = "BAT0" ``` Each will be `true` only if the named value is available on your system. If the