1
Fork 0
mirror of https://github.com/RGBCube/superfreq synced 2025-07-27 17:07:44 +00:00

treewide: toml and rust formatter changes

This commit is contained in:
RGBCube 2025-06-12 20:19:40 +03:00
parent fb5ef3d3d2
commit e9e1df90e6
Signed by: RGBCube
SSH key fingerprint: SHA256:CzqbPcfwt+GxFYNnFVCqoN5Itn4YFrshg1TrnACpA5M
12 changed files with 2456 additions and 2182 deletions

30
.rustfmt.toml Normal file
View file

@ -0,0 +1,30 @@
# Taken from https://github.com/cull-os/carcass.
# Modified to have 2 space indents and 80 line width.
# float_literal_trailing_zero = "Always" # TODO: Warning for some reason?
condense_wildcard_suffixes = true
doc_comment_code_block_width = 80
edition = "2024" # Keep in sync with Cargo.toml.
enum_discrim_align_threshold = 60
force_explicit_abi = false
force_multiline_blocks = true
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
hex_literal_case = "Upper"
imports_granularity = "Crate"
imports_layout = "Vertical"
inline_attribute_width = 60
match_block_trailing_comma = true
max_width = 80
newline_style = "Unix"
normalize_comments = true
normalize_doc_attributes = true
overflow_delimited_expr = true
struct_field_align_threshold = 60
tab_spaces = 2
unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

15
.taplo.toml Normal file
View file

@ -0,0 +1,15 @@
# Taken from https://github.com/cull-os/carcass.
[formatting]
align_entries = true
column_width = 100
compact_arrays = false
reorder_inline_tables = true
reorder_keys = true
[[rule]]
include = [ "**/Cargo.toml" ]
keys = [ "package" ]
[rule.formatting]
reorder_keys = false

View file

@ -1,21 +1,21 @@
[package] [package]
name = "watt" name = "watt"
description = "Modern CPU frequency and power management utility for Linux" description = "Modern CPU frequency and power management utility for Linux"
version = "0.4.0" version = "0.4.0"
edition = "2024" edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>", "RGBCube <git@rgbcu.be>"] authors = [ "NotAShelf <raf@notashelf.dev>", "RGBCube <git@rgbcu.be>" ]
rust-version = "1.85" rust-version = "1.85"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
clap = { version = "4.0", features = ["derive", "env"] } clap = { version = "4.0", features = [ "derive", "env" ] }
clap-verbosity-flag = "3.0.2" clap-verbosity-flag = "3.0.2"
ctrlc = "3.4" ctrlc = "3.4"
derive_more = { version = "2.0.1", features = ["full"] } derive_more = { version = "2.0.1", features = [ "full" ] }
env_logger = "0.11" env_logger = "0.11"
log = "0.4" log = "0.4"
num_cpus = "1.16" num_cpus = "1.16"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = [ "derive" ] }
thiserror = "2.0" thiserror = "2.0"
toml = "0.8" toml = "0.8"
yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] } yansi = { version = "1.0.1", features = [ "detect-env", "detect-tty" ] }

View file

@ -1,52 +1,57 @@
use std::env; use std::{
use std::fs; env,
use std::path::PathBuf; fs,
path::PathBuf,
};
const MULTICALL_NAMES: &[&str] = &["cpu", "power"]; const MULTICALL_NAMES: &[&str] = &["cpu", "power"];
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=target"); println!("cargo:rerun-if-changed=target");
let out_dir = PathBuf::from(env::var("OUT_DIR")?); let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let target = out_dir let target = out_dir
.parent() // target/debug/build/<pkg>-<hash>/out .parent() // target/debug/build/<pkg>-<hash>/out
.and_then(|p| p.parent()) // target/debug/build/<pkg>-<hash> .and_then(|p| p.parent()) // target/debug/build/<pkg>-<hash>
.and_then(|p| p.parent()) // target/debug/ .and_then(|p| p.parent()) // target/debug/
.ok_or("failed to find target directory")?; .ok_or("failed to find target directory")?;
let main_binary_name = env::var("CARGO_PKG_NAME")?; let main_binary_name = env::var("CARGO_PKG_NAME")?;
let main_binary_path = target.join(&main_binary_name); let main_binary_path = target.join(&main_binary_name);
let mut errored = false; let mut errored = false;
for name in MULTICALL_NAMES { for name in MULTICALL_NAMES {
let hardlink_path = target.join(name); let hardlink_path = target.join(name);
if hardlink_path.exists() { if hardlink_path.exists() {
if hardlink_path.is_dir() { if hardlink_path.is_dir() {
fs::remove_dir_all(&hardlink_path)?; fs::remove_dir_all(&hardlink_path)?;
} else { } else {
fs::remove_file(&hardlink_path)?; 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 { if let Err(error) = fs::hard_link(&main_binary_path, &hardlink_path) {
println!( println!(
"cargo:warning=this often happens because the target binary isn't built yet, try running `cargo build` again" "cargo:warning=failed to create hard link '{path}': {error}",
); path = hardlink_path.display(),
println!("cargo:warning=keep in mind that this is for development purposes only"); );
errored = true;
} }
}
Ok(()) 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(())
} }

View file

@ -5,107 +5,101 @@
# Emergency thermal protection (highest priority). # Emergency thermal protection (highest priority).
[[rule]] [[rule]]
priority = 100
if = { value = "$cpu-temperature", is-more-than = 85.0 }
cpu.governor = "powersave"
cpu.energy-performance-preference = "power" cpu.energy-performance-preference = "power"
cpu.frequency-mhz-maximum = 2000 cpu.frequency-mhz-maximum = 2000
cpu.turbo = false cpu.governor = "powersave"
cpu.turbo = false
if = { value = "$cpu-temperature", is-more-than = 85.0 }
priority = 100
# Critical battery preservation. # Critical battery preservation.
[[rule]] [[rule]]
priority = 90
if.all = [
"?discharging",
{ value = "%power-supply-charge", is-less-than = 0.3 },
]
cpu.governor = "powersave"
cpu.energy-performance-preference = "power" cpu.energy-performance-preference = "power"
cpu.frequency-mhz-maximum = 800 # More aggressive below critical threshold. cpu.frequency-mhz-maximum = 800 # More aggressive below critical threshold.
cpu.turbo = false cpu.governor = "powersave"
power.platform-profile = "low-power" cpu.turbo = false
if.all = [ "?discharging", { value = "%power-supply-charge", is-less-than = 0.3 } ]
power.platform-profile = "low-power"
priority = 90
# High performance mode for sustained high load. # High performance mode for sustained high load.
[[rule]] [[rule]]
priority = 80 cpu.energy-performance-preference = "performance"
cpu.governor = "performance"
cpu.turbo = true
if.all = [ if.all = [
{ value = "%cpu-usage", is-more-than = 0.8 }, { value = "%cpu-usage", is-more-than = 0.8 },
{ value = "$cpu-idle-seconds", is-less-than = 30.0 }, { value = "$cpu-idle-seconds", is-less-than = 30.0 },
{ value = "$cpu-temperature", is-less-than = 75.0 }, { value = "$cpu-temperature", is-less-than = 75.0 },
] ]
cpu.governor = "performance" priority = 80
cpu.energy-performance-preference = "performance"
cpu.turbo = true
# Performance mode when not discharging. # Performance mode when not discharging.
[[rule]] [[rule]]
priority = 70 cpu.energy-performance-bias = "balance_performance"
cpu.energy-performance-preference = "performance"
cpu.governor = "performance"
cpu.turbo = true
if.all = [ if.all = [
{ not = "?discharging" }, { not = "?discharging" },
{ value = "%cpu-usage", is-more-than = 0.1 }, { value = "%cpu-usage", is-more-than = 0.1 },
{ value = "$cpu-temperature", is-less-than = 80.0 }, { value = "$cpu-temperature", is-less-than = 80.0 },
] ]
cpu.governor = "performance" priority = 70
cpu.energy-performance-preference = "performance"
cpu.energy-performance-bias = "balance_performance"
cpu.turbo = true
# Moderate performance for medium load. # Moderate performance for medium load.
[[rule]] [[rule]]
priority = 60 cpu.energy-performance-preference = "balance_performance"
cpu.governor = "schedutil"
if.all = [ if.all = [
{ value = "%cpu-usage", is-more-than = 0.4 }, { value = "%cpu-usage", is-more-than = 0.4 },
{ value = "%cpu-usage", is-less-than = 0.8 }, { value = "%cpu-usage", is-less-than = 0.8 },
] ]
cpu.governor = "schedutil" priority = 60
cpu.energy-performance-preference = "balance_performance"
# Power saving during low activity. # Power saving during low activity.
[[rule]] [[rule]]
priority = 50 cpu.energy-performance-preference = "power"
cpu.governor = "powersave"
cpu.turbo = false
if.all = [ if.all = [
{ value = "%cpu-usage", is-less-than = 0.2 }, { value = "%cpu-usage", is-less-than = 0.2 },
{ value = "$cpu-idle-seconds", is-more-than = 60.0 }, { value = "$cpu-idle-seconds", is-more-than = 60.0 },
] ]
cpu.governor = "powersave" priority = 50
cpu.energy-performance-preference = "power"
cpu.turbo = false
# Extended idle power optimization. # Extended idle power optimization.
[[rule]] [[rule]]
priority = 40
if = { value = "$cpu-idle-seconds", is-more-than = 300.0 }
cpu.governor = "powersave"
cpu.energy-performance-preference = "power" cpu.energy-performance-preference = "power"
cpu.frequency-mhz-maximum = 1600 cpu.frequency-mhz-maximum = 1600
cpu.turbo = false cpu.governor = "powersave"
cpu.turbo = false
if = { value = "$cpu-idle-seconds", is-more-than = 300.0 }
priority = 40
# Battery conservation when discharging. # Battery conservation when discharging.
[[rule]] [[rule]]
priority = 30
if.all = [
"?discharging",
{ value = "%power-supply-charge", is-less-than = 0.5 },
]
cpu.governor = "powersave"
cpu.energy-performance-preference = "power" cpu.energy-performance-preference = "power"
cpu.frequency-mhz-maximum = 2000 cpu.frequency-mhz-maximum = 2000
cpu.turbo = false cpu.governor = "powersave"
power.platform-profile = "low-power" cpu.turbo = false
if.all = [ "?discharging", { value = "%power-supply-charge", is-less-than = 0.5 } ]
power.platform-profile = "low-power"
priority = 30
# General battery mode. # General battery mode.
[[rule]] [[rule]]
priority = 20 cpu.energy-performance-bias = "balance_power"
if = "?discharging"
cpu.governor = "powersave"
cpu.energy-performance-preference = "power" cpu.energy-performance-preference = "power"
cpu.energy-performance-bias = "balance_power" cpu.frequency-mhz-maximum = 1800
cpu.frequency-mhz-maximum = 1800 cpu.frequency-mhz-minimum = 200
cpu.frequency-mhz-minimum = 200 cpu.governor = "powersave"
cpu.turbo = false cpu.turbo = false
if = "?discharging"
priority = 20
# Balanced performance for general use. Default fallback rule. # Balanced performance for general use. Default fallback rule.
[[rule]] [[rule]]
priority = 0
cpu.governor = "schedutil"
cpu.energy-performance-preference = "balance_performance" cpu.energy-performance-preference = "balance_performance"
cpu.governor = "schedutil"
priority = 0

View file

@ -1,526 +1,580 @@
use std::{fs, path::Path}; use std::{
fs,
path::Path,
};
use anyhow::{Context, bail}; use anyhow::{
use serde::{Deserialize, Serialize}; Context,
bail,
};
use serde::{
Deserialize,
Serialize,
};
use crate::{cpu, power_supply}; use crate::{
cpu,
power_supply,
};
fn is_default<T: Default + PartialEq>(value: &T) -> bool { fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default() *value == T::default()
} }
#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)] #[derive(
Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq,
)]
#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] #[serde(deny_unknown_fields, default, rename_all = "kebab-case")]
pub struct CpuDelta { pub struct CpuDelta {
/// The CPUs to apply the changes to. When unspecified, will be applied to all CPUs. /// The CPUs to apply the changes to. When unspecified, will be applied to
#[arg(short = 'c', long = "for")] /// all CPUs.
#[serde(rename = "for", skip_serializing_if = "is_default")] #[arg(short = 'c', long = "for")]
pub for_: Option<Vec<u32>>, #[serde(rename = "for", skip_serializing_if = "is_default")]
pub for_: Option<Vec<u32>>,
/// Set the CPU governor. /// Set the CPU governor.
#[arg(short = 'g', long)] #[arg(short = 'g', long)]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub governor: Option<String>, // TODO: Validate with clap for available governors. pub governor: Option<String>, /* TODO: Validate with clap for available
* governors. */
/// Set CPU Energy Performance Preference (EPP). Short form: --epp. /// Set CPU Energy Performance Preference (EPP). Short form: --epp.
#[arg(short = 'p', long, alias = "epp")] #[arg(short = 'p', long, alias = "epp")]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub energy_performance_preference: Option<String>, // TODO: Validate with clap for available governors. pub energy_performance_preference: Option<String>, /* TODO: Validate with
* clap for available
* governors. */
/// Set CPU Energy Performance Bias (EPB). Short form: --epb. /// Set CPU Energy Performance Bias (EPB). Short form: --epb.
#[arg(short = 'b', long, alias = "epb")] #[arg(short = 'b', long, alias = "epb")]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub energy_performance_bias: Option<String>, // TODO: Validate with clap for available governors. pub energy_performance_bias: Option<String>, /* TODO: Validate with clap for available governors. */
/// Set minimum CPU frequency in MHz. Short form: --freq-min. /// Set minimum CPU frequency in MHz. Short form: --freq-min.
#[arg(short = 'f', long, alias = "freq-min", value_parser = clap::value_parser!(u64).range(1..=10_000))] #[arg(short = 'f', long, alias = "freq-min", value_parser = clap::value_parser!(u64).range(1..=10_000))]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub frequency_mhz_minimum: Option<u64>, pub frequency_mhz_minimum: Option<u64>,
/// Set maximum CPU frequency in MHz. Short form: --freq-max. /// Set maximum CPU frequency in MHz. Short form: --freq-max.
#[arg(short = 'F', long, alias = "freq-max", value_parser = clap::value_parser!(u64).range(1..=10_000))] #[arg(short = 'F', long, alias = "freq-max", value_parser = clap::value_parser!(u64).range(1..=10_000))]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub frequency_mhz_maximum: Option<u64>, pub frequency_mhz_maximum: Option<u64>,
/// Set turbo boost behaviour. Has to be for all CPUs. /// Set turbo boost behaviour. Has to be for all CPUs.
#[arg(short = 't', long, conflicts_with = "for_")] #[arg(short = 't', long, conflicts_with = "for_")]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub turbo: Option<bool>, pub turbo: Option<bool>,
} }
impl CpuDelta { impl CpuDelta {
pub fn apply(&self) -> anyhow::Result<()> { pub fn apply(&self) -> anyhow::Result<()> {
let mut cpus = match &self.for_ { let mut cpus = match &self.for_ {
Some(numbers) => { Some(numbers) => {
let mut cpus = Vec::with_capacity(numbers.len()); let mut cpus = Vec::with_capacity(numbers.len());
let cache = cpu::CpuRescanCache::default(); let cache = cpu::CpuRescanCache::default();
for &number in numbers { for &number in numbers {
cpus.push(cpu::Cpu::new(number, &cache)?); cpus.push(cpu::Cpu::new(number, &cache)?);
}
cpus
}
None => cpu::Cpu::all().context("failed to get all CPUs and their information")?,
};
for cpu in &mut cpus {
if let Some(governor) = self.governor.as_ref() {
cpu.set_governor(governor)?;
}
if let Some(epp) = self.energy_performance_preference.as_ref() {
cpu.set_epp(epp)?;
}
if let Some(epb) = self.energy_performance_bias.as_ref() {
cpu.set_epb(epb)?;
}
if let Some(mhz_minimum) = self.frequency_mhz_minimum {
cpu.set_frequency_mhz_minimum(mhz_minimum)?;
}
if let Some(mhz_maximum) = self.frequency_mhz_maximum {
cpu.set_frequency_mhz_maximum(mhz_maximum)?;
}
} }
if let Some(turbo) = self.turbo { cpus
cpu::Cpu::set_turbo(turbo)?; },
} None => {
cpu::Cpu::all()
.context("failed to get all CPUs and their information")?
},
};
Ok(()) for cpu in &mut cpus {
if let Some(governor) = self.governor.as_ref() {
cpu.set_governor(governor)?;
}
if let Some(epp) = self.energy_performance_preference.as_ref() {
cpu.set_epp(epp)?;
}
if let Some(epb) = self.energy_performance_bias.as_ref() {
cpu.set_epb(epb)?;
}
if let Some(mhz_minimum) = self.frequency_mhz_minimum {
cpu.set_frequency_mhz_minimum(mhz_minimum)?;
}
if let Some(mhz_maximum) = self.frequency_mhz_maximum {
cpu.set_frequency_mhz_maximum(mhz_maximum)?;
}
} }
if let Some(turbo) = self.turbo {
cpu::Cpu::set_turbo(turbo)?;
}
Ok(())
}
} }
#[derive(Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq)] #[derive(
Serialize, Deserialize, clap::Parser, Default, Debug, Clone, PartialEq, Eq,
)]
#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] #[serde(deny_unknown_fields, default, rename_all = "kebab-case")]
pub struct PowerDelta { pub struct PowerDelta {
/// The power supplies to apply the changes to. When unspecified, will be applied to all power supplies. /// The power supplies to apply the changes to. When unspecified, will be
#[arg(short = 'p', long = "for")] /// applied to all power supplies.
#[serde(rename = "for", skip_serializing_if = "is_default")] #[arg(short = 'p', long = "for")]
pub for_: Option<Vec<String>>, #[serde(rename = "for", skip_serializing_if = "is_default")]
pub for_: Option<Vec<String>>,
/// Set the percentage that the power supply has to drop under for charging to start. Short form: --charge-start. /// Set the percentage that the power supply has to drop under for charging
#[arg(short = 'c', long, alias = "charge-start", value_parser = clap::value_parser!(u8).range(0..=100))] /// to start. Short form: --charge-start.
#[serde(skip_serializing_if = "is_default")] #[arg(short = 'c', long, alias = "charge-start", value_parser = clap::value_parser!(u8).range(0..=100))]
pub charge_threshold_start: Option<u8>, #[serde(skip_serializing_if = "is_default")]
pub charge_threshold_start: Option<u8>,
/// Set the percentage where charging will stop. Short form: --charge-end. /// Set the percentage where charging will stop. Short form: --charge-end.
#[arg(short = 'C', long, alias = "charge-end", value_parser = clap::value_parser!(u8).range(0..=100))] #[arg(short = 'C', long, alias = "charge-end", value_parser = clap::value_parser!(u8).range(0..=100))]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub charge_threshold_end: Option<u8>, pub charge_threshold_end: Option<u8>,
/// Set ACPI platform profile. Has to be for all power supplies. /// Set ACPI platform profile. Has to be for all power supplies.
#[arg(short = 'f', long, alias = "profile", conflicts_with = "for_")] #[arg(short = 'f', long, alias = "profile", conflicts_with = "for_")]
#[serde(skip_serializing_if = "is_default")] #[serde(skip_serializing_if = "is_default")]
pub platform_profile: Option<String>, pub platform_profile: Option<String>,
} }
impl PowerDelta { impl PowerDelta {
pub fn apply(&self) -> anyhow::Result<()> { pub fn apply(&self) -> anyhow::Result<()> {
let mut power_supplies = match &self.for_ { let mut power_supplies = match &self.for_ {
Some(names) => { Some(names) => {
let mut power_supplies = Vec::with_capacity(names.len()); let mut power_supplies = Vec::with_capacity(names.len());
for name in names { for name in names {
power_supplies.push(power_supply::PowerSupply::from_name(name.clone())?); power_supplies
} .push(power_supply::PowerSupply::from_name(name.clone())?);
power_supplies
}
None => power_supply::PowerSupply::all()?
.into_iter()
.filter(|power_supply| power_supply.threshold_config.is_some())
.collect(),
};
for power_supply in &mut power_supplies {
if let Some(threshold_start) = self.charge_threshold_start {
power_supply.set_charge_threshold_start(threshold_start as f64 / 100.0)?;
}
if let Some(threshold_end) = self.charge_threshold_end {
power_supply.set_charge_threshold_end(threshold_end as f64 / 100.0)?;
}
} }
if let Some(platform_profile) = self.platform_profile.as_ref() { power_supplies
power_supply::PowerSupply::set_platform_profile(platform_profile)?; },
}
Ok(()) None => {
power_supply::PowerSupply::all()?
.into_iter()
.filter(|power_supply| power_supply.threshold_config.is_some())
.collect()
},
};
for power_supply in &mut power_supplies {
if let Some(threshold_start) = self.charge_threshold_start {
power_supply
.set_charge_threshold_start(threshold_start as f64 / 100.0)?;
}
if let Some(threshold_end) = self.charge_threshold_end {
power_supply.set_charge_threshold_end(threshold_end as f64 / 100.0)?;
}
} }
if let Some(platform_profile) = self.platform_profile.as_ref() {
power_supply::PowerSupply::set_platform_profile(platform_profile)?;
}
Ok(())
}
} }
macro_rules! named { macro_rules! named {
($variant:ident => $value:literal) => { ($variant:ident => $value:literal) => {
pub mod $variant { pub mod $variant {
pub fn serialize<S: serde::Serializer>(serializer: S) -> Result<S::Ok, S::Error> { pub fn serialize<S: serde::Serializer>(
serializer.serialize_str($value) serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str($value)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<(), D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = ();
fn expecting(
&self,
writer: &mut std::fmt::Formatter,
) -> std::fmt::Result {
writer.write_str(concat!("\"", $value, "\""))
}
fn visit_str<E: serde::de::Error>(
self,
value: &str,
) -> Result<Self::Value, E> {
if value != $value {
return Err(E::invalid_value(
serde::de::Unexpected::Str(value),
&self,
));
} }
pub fn deserialize<'de, D: serde::Deserializer<'de>>( Ok(())
deserializer: D, }
) -> Result<(), D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = ();
fn expecting(&self, writer: &mut std::fmt::Formatter) -> std::fmt::Result {
writer.write_str(concat!("\"", $value, "\""))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
if value != $value {
return Err(E::invalid_value(serde::de::Unexpected::Str(value), &self));
}
Ok(())
}
}
deserializer.deserialize_str(Visitor)
}
} }
};
deserializer.deserialize_str(Visitor)
}
}
};
} }
mod expression { mod expression {
named!(cpu_usage => "%cpu-usage"); named!(cpu_usage => "%cpu-usage");
named!(cpu_usage_volatility => "$cpu-usage-volatility"); named!(cpu_usage_volatility => "$cpu-usage-volatility");
named!(cpu_temperature => "$cpu-temperature"); named!(cpu_temperature => "$cpu-temperature");
named!(cpu_temperature_volatility => "$cpu-temperature-volatility"); named!(cpu_temperature_volatility => "$cpu-temperature-volatility");
named!(cpu_idle_seconds => "$cpu-idle-seconds"); named!(cpu_idle_seconds => "$cpu-idle-seconds");
named!(power_supply_charge => "%power-supply-charge"); named!(power_supply_charge => "%power-supply-charge");
named!(power_supply_discharge_rate => "%power-supply-discharge-rate"); named!(power_supply_discharge_rate => "%power-supply-discharge-rate");
named!(discharging => "?discharging"); named!(discharging => "?discharging");
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(untagged)] #[serde(untagged)]
pub enum Expression { pub enum Expression {
#[serde(with = "expression::cpu_usage")] #[serde(with = "expression::cpu_usage")]
CpuUsage, CpuUsage,
#[serde(with = "expression::cpu_usage_volatility")] #[serde(with = "expression::cpu_usage_volatility")]
CpuUsageVolatility, CpuUsageVolatility,
#[serde(with = "expression::cpu_temperature")] #[serde(with = "expression::cpu_temperature")]
CpuTemperature, CpuTemperature,
#[serde(with = "expression::cpu_temperature_volatility")] #[serde(with = "expression::cpu_temperature_volatility")]
CpuTemperatureVolatility, CpuTemperatureVolatility,
#[serde(with = "expression::cpu_idle_seconds")] #[serde(with = "expression::cpu_idle_seconds")]
CpuIdleSeconds, CpuIdleSeconds,
#[serde(with = "expression::power_supply_charge")] #[serde(with = "expression::power_supply_charge")]
PowerSupplyCharge, PowerSupplyCharge,
#[serde(with = "expression::power_supply_discharge_rate")] #[serde(with = "expression::power_supply_discharge_rate")]
PowerSupplyDischargeRate, PowerSupplyDischargeRate,
#[serde(with = "expression::discharging")] #[serde(with = "expression::discharging")]
Discharging, Discharging,
Boolean(bool), Boolean(bool),
Number(f64), Number(f64),
Plus { Plus {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "plus")] #[serde(rename = "plus")]
b: Box<Expression>, b: Box<Expression>,
}, },
Minus { Minus {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "minus")] #[serde(rename = "minus")]
b: Box<Expression>, b: Box<Expression>,
}, },
Multiply { Multiply {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "multiply")] #[serde(rename = "multiply")]
b: Box<Expression>, b: Box<Expression>,
}, },
Power { Power {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "power")] #[serde(rename = "power")]
b: Box<Expression>, b: Box<Expression>,
}, },
Divide { Divide {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "divide")] #[serde(rename = "divide")]
b: Box<Expression>, b: Box<Expression>,
}, },
LessThan { LessThan {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "is-less-than")] #[serde(rename = "is-less-than")]
b: Box<Expression>, b: Box<Expression>,
}, },
MoreThan { MoreThan {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "is-more-than")] #[serde(rename = "is-more-than")]
b: Box<Expression>, b: Box<Expression>,
}, },
Equal { Equal {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "is-equal")] #[serde(rename = "is-equal")]
b: Box<Expression>, b: Box<Expression>,
leeway: Box<Expression>, leeway: Box<Expression>,
}, },
And { And {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "and")] #[serde(rename = "and")]
b: Box<Expression>, b: Box<Expression>,
}, },
All { All {
all: Vec<Expression>, all: Vec<Expression>,
}, },
Or { Or {
#[serde(rename = "value")] #[serde(rename = "value")]
a: Box<Expression>, a: Box<Expression>,
#[serde(rename = "or")] #[serde(rename = "or")]
b: Box<Expression>, b: Box<Expression>,
}, },
Any { Any {
any: Vec<Expression>, any: Vec<Expression>,
}, },
Not { Not {
not: Box<Expression>, not: Box<Expression>,
}, },
} }
impl Default for Expression { impl Default for Expression {
fn default() -> Self { fn default() -> Self {
Self::Boolean(true) Self::Boolean(true)
} }
} }
impl Expression { impl Expression {
pub fn as_number(&self) -> anyhow::Result<f64> { pub fn as_number(&self) -> anyhow::Result<f64> {
let Self::Number(number) = self else { let Self::Number(number) = self else {
bail!("tried to cast '{self:?}' to a number, failed") bail!("tried to cast '{self:?}' to a number, failed")
}; };
Ok(*number) Ok(*number)
} }
pub fn as_boolean(&self) -> anyhow::Result<bool> { pub fn as_boolean(&self) -> anyhow::Result<bool> {
let Self::Boolean(boolean) = self else { let Self::Boolean(boolean) = self else {
bail!("tried to cast '{self:?}' to a boolean, failed") bail!("tried to cast '{self:?}' to a boolean, failed")
}; };
Ok(*boolean) Ok(*boolean)
} }
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct EvalState { pub struct EvalState {
pub cpu_usage: f64, pub cpu_usage: f64,
pub cpu_usage_volatility: Option<f64>, pub cpu_usage_volatility: Option<f64>,
pub cpu_temperature: f64, pub cpu_temperature: f64,
pub cpu_temperature_volatility: Option<f64>, pub cpu_temperature_volatility: Option<f64>,
pub cpu_idle_seconds: f64, pub cpu_idle_seconds: f64,
pub power_supply_charge: f64, pub power_supply_charge: f64,
pub power_supply_discharge_rate: Option<f64>, pub power_supply_discharge_rate: Option<f64>,
pub discharging: bool, pub discharging: bool,
} }
impl Expression { impl Expression {
pub fn eval(&self, state: &EvalState) -> anyhow::Result<Option<Expression>> { pub fn eval(&self, state: &EvalState) -> anyhow::Result<Option<Expression>> {
use Expression::*; use Expression::*;
macro_rules! try_ok { macro_rules! try_ok {
($expression:expr) => { ($expression:expr) => {
match $expression { match $expression {
Some(value) => value, Some(value) => value,
None => return Ok(None), 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()?),
}))
} }
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)] #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")] #[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Rule { pub struct Rule {
pub priority: u8, pub priority: u8,
#[serde(default, rename = "if", skip_serializing_if = "is_default")] #[serde(default, rename = "if", skip_serializing_if = "is_default")]
pub condition: Expression, pub condition: Expression,
#[serde(default, skip_serializing_if = "is_default")] #[serde(default, skip_serializing_if = "is_default")]
pub cpu: CpuDelta, pub cpu: CpuDelta,
#[serde(default, skip_serializing_if = "is_default")] #[serde(default, skip_serializing_if = "is_default")]
pub power: PowerDelta, pub power: PowerDelta,
} }
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default, rename_all = "kebab-case")] #[serde(default, rename_all = "kebab-case")]
pub struct DaemonConfig { pub struct DaemonConfig {
#[serde(rename = "rule")] #[serde(rename = "rule")]
pub rules: Vec<Rule>, pub rules: Vec<Rule>,
} }
impl DaemonConfig { impl DaemonConfig {
const DEFAULT: &str = include_str!("../config.toml"); const DEFAULT: &str = include_str!("../config.toml");
pub fn load_from(path: Option<&Path>) -> anyhow::Result<Self> { pub fn load_from(path: Option<&Path>) -> anyhow::Result<Self> {
let contents = if let Some(path) = path { let contents = if let Some(path) = path {
log::debug!("loading config from '{path}'", path = path.display()); log::debug!("loading config from '{path}'", path = path.display());
&fs::read_to_string(path).with_context(|| { &fs::read_to_string(path).with_context(|| {
format!("failed to read config from '{path}'", path = path.display()) format!("failed to read config from '{path}'", path = path.display())
})? })?
} else { } else {
log::debug!( log::debug!(
"loading default config from embedded toml:\n{config}", "loading default config from embedded toml:\n{config}",
config = Self::DEFAULT, config = Self::DEFAULT,
); );
Self::DEFAULT Self::DEFAULT
}; };
let mut config: Self = toml::from_str(contents).with_context(|| { let mut config: Self = toml::from_str(contents).with_context(|| {
path.map_or( path.map_or(
"failed to parse builtin default config, this is a bug".to_owned(), "failed to parse builtin default config, this is a bug".to_owned(),
|p| format!("failed to parse file at '{path}'", path = p.display()), |p| format!("failed to parse file at '{path}'", path = p.display()),
) )
})?; })?;
{ {
let mut priorities = Vec::with_capacity(config.rules.len()); let mut priorities = Vec::with_capacity(config.rules.len());
for rule in &config.rules { for rule in &config.rules {
if priorities.contains(&rule.priority) { if priorities.contains(&rule.priority) {
bail!("each config rule must have a different priority") bail!("each config rule must have a different priority")
}
priorities.push(rule.priority);
}
} }
// This is just for debug traces. priorities.push(rule.priority);
if log::max_level() >= log::LevelFilter::Debug { }
if config.rules.is_sorted_by_key(|rule| rule.priority) {
log::debug!("config rules are sorted by increasing priority, not doing anything");
} else {
log::debug!("config rules aren't sorted by priority, sorting");
}
}
config.rules.sort_by_key(|rule| rule.priority);
log::debug!("loaded config: {config:#?}");
Ok(config)
} }
// This is just for debug traces.
if log::max_level() >= log::LevelFilter::Debug {
if config.rules.is_sorted_by_key(|rule| rule.priority) {
log::debug!(
"config rules are sorted by increasing priority, not doing anything"
);
} else {
log::debug!("config rules aren't sorted by priority, sorting");
}
}
config.rules.sort_by_key(|rule| rule.priority);
log::debug!("loaded config: {config:#?}");
Ok(config)
}
} }

1144
src/cpu.rs

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,29 @@
use std::{ use std::{
cell::LazyCell, cell::LazyCell,
collections::{HashMap, VecDeque}, collections::{
sync::{ HashMap,
Arc, VecDeque,
atomic::{AtomicBool, Ordering}, },
sync::{
Arc,
atomic::{
AtomicBool,
Ordering,
}, },
thread, },
time::{Duration, Instant}, thread,
time::{
Duration,
Instant,
},
}; };
use anyhow::Context; use anyhow::Context;
use crate::{config, system}; use crate::{
config,
system,
};
/// Calculate the idle time multiplier based on system idle time. /// Calculate the idle time multiplier based on system idle time.
/// ///
@ -19,419 +31,433 @@ use crate::{config, system};
/// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0 /// - For idle times < 2 minutes: Linear interpolation from 1.0 to 2.0
/// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes)) /// - For idle times >= 2 minutes: Logarithmic scaling (1.0 + log2(minutes))
fn idle_multiplier(idle_for: Duration) -> f64 { fn idle_multiplier(idle_for: Duration) -> f64 {
let factor = match idle_for.as_secs() < 120 { let factor = match idle_for.as_secs() < 120 {
// Less than 2 minutes. // Less than 2 minutes.
// Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s) // Linear interpolation from 1.0 (at 0s) to 2.0 (at 120s)
true => (idle_for.as_secs() as f64) / 120.0, true => (idle_for.as_secs() as f64) / 120.0,
// 2 minutes or more. // 2 minutes or more.
// Logarithmic scaling: 1.0 + log2(minutes) // Logarithmic scaling: 1.0 + log2(minutes)
false => { false => {
let idle_minutes = idle_for.as_secs() as f64 / 60.0; let idle_minutes = idle_for.as_secs() as f64 / 60.0;
idle_minutes.log2() idle_minutes.log2()
} },
}; };
// Clamp the multiplier to avoid excessive delays. // Clamp the multiplier to avoid excessive delays.
(1.0 + factor).clamp(1.0, 5.0) (1.0 + factor).clamp(1.0, 5.0)
} }
#[derive(Debug)] #[derive(Debug)]
struct Daemon { struct Daemon {
/// Last time when there was user activity. /// Last time when there was user activity.
last_user_activity: Instant, last_user_activity: Instant,
/// The last computed polling delay. /// The last computed polling delay.
last_polling_delay: Option<Duration>, last_polling_delay: Option<Duration>,
/// The system state. /// The system state.
system: system::System, system: system::System,
/// CPU usage and temperature log. /// CPU usage and temperature log.
cpu_log: VecDeque<CpuLog>, cpu_log: VecDeque<CpuLog>,
/// Power supply status log. /// Power supply status log.
power_supply_log: VecDeque<PowerSupplyLog>, power_supply_log: VecDeque<PowerSupplyLog>,
} }
impl Daemon { impl Daemon {
fn rescan(&mut self) -> anyhow::Result<()> { fn rescan(&mut self) -> anyhow::Result<()> {
self.system.rescan()?; self.system.rescan()?;
log::debug!("appending daemon logs..."); log::debug!("appending daemon logs...");
let at = Instant::now(); let at = Instant::now();
while self.cpu_log.len() > 100 { while self.cpu_log.len() > 100 {
log::debug!("daemon CPU log was too long, popping element"); log::debug!("daemon CPU log was too long, popping element");
self.cpu_log.pop_front(); self.cpu_log.pop_front();
}
let cpu_log = CpuLog {
at,
usage: self
.system
.cpus
.iter()
.map(|cpu| cpu.stat.usage())
.sum::<f64>()
/ self.system.cpus.len() as f64,
temperature: self.system.cpu_temperatures.values().sum::<f64>()
/ self.system.cpu_temperatures.len() as f64,
};
log::debug!("appending CPU log item: {cpu_log:?}");
self.cpu_log.push_back(cpu_log);
while self.power_supply_log.len() > 100 {
log::debug!("daemon power supply log was too long, popping element");
self.power_supply_log.pop_front();
}
let power_supply_log = PowerSupplyLog {
at,
charge: {
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)
}
},
);
charge_sum / charge_nr as f64
},
};
log::debug!("appending power supply log item: {power_supply_log:?}");
self.power_supply_log.push_back(power_supply_log);
Ok(())
} }
let cpu_log = CpuLog {
at,
usage: self
.system
.cpus
.iter()
.map(|cpu| cpu.stat.usage())
.sum::<f64>()
/ self.system.cpus.len() as f64,
temperature: self.system.cpu_temperatures.values().sum::<f64>()
/ self.system.cpu_temperatures.len() as f64,
};
log::debug!("appending CPU log item: {cpu_log:?}");
self.cpu_log.push_back(cpu_log);
while self.power_supply_log.len() > 100 {
log::debug!("daemon power supply log was too long, popping element");
self.power_supply_log.pop_front();
}
let power_supply_log = PowerSupplyLog {
at,
charge: {
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)
}
},
);
charge_sum / charge_nr as f64
},
};
log::debug!("appending power supply log item: {power_supply_log:?}");
self.power_supply_log.push_back(power_supply_log);
Ok(())
}
} }
#[derive(Debug)] #[derive(Debug)]
struct CpuLog { struct CpuLog {
at: Instant, at: Instant,
/// CPU usage between 0-1, a percentage. /// CPU usage between 0-1, a percentage.
usage: f64, usage: f64,
/// CPU temperature in celsius. /// CPU temperature in celsius.
temperature: f64, temperature: f64,
} }
#[derive(Debug)] #[derive(Debug)]
struct CpuVolatility { struct CpuVolatility {
usage: f64, usage: f64,
temperature: f64, temperature: f64,
} }
impl Daemon { impl Daemon {
fn cpu_volatility(&self) -> Option<CpuVolatility> { fn cpu_volatility(&self) -> Option<CpuVolatility> {
let recent_log_count = self let recent_log_count = self
.cpu_log .cpu_log
.iter() .iter()
.rev() .rev()
.take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60)) .take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60))
.count(); .count();
if recent_log_count < 2 { if recent_log_count < 2 {
return None; return None;
}
if self.cpu_log.len() < 2 {
return None;
}
let change_count = self.cpu_log.len() - 1;
let mut usage_change_sum = 0.0;
let mut temperature_change_sum = 0.0;
for index in 0..change_count {
let usage_change = self.cpu_log[index + 1].usage - self.cpu_log[index].usage;
usage_change_sum += usage_change.abs();
let temperature_change =
self.cpu_log[index + 1].temperature - self.cpu_log[index].temperature;
temperature_change_sum += temperature_change.abs();
}
Some(CpuVolatility {
usage: usage_change_sum / change_count as f64,
temperature: temperature_change_sum / change_count as f64,
})
} }
fn is_cpu_idle(&self) -> bool { if self.cpu_log.len() < 2 {
let recent_log_count = self return None;
.cpu_log
.iter()
.rev()
.take_while(|log| log.at.elapsed() < Duration::from_secs(5 * 60))
.count();
if recent_log_count < 2 {
return false;
}
let recent_average = self
.cpu_log
.iter()
.rev()
.take(recent_log_count)
.map(|log| log.usage)
.sum::<f64>()
/ recent_log_count as f64;
recent_average < 0.1
&& self
.cpu_volatility()
.is_none_or(|volatility| volatility.usage < 0.05)
} }
let change_count = self.cpu_log.len() - 1;
let mut usage_change_sum = 0.0;
let mut temperature_change_sum = 0.0;
for index in 0..change_count {
let usage_change =
self.cpu_log[index + 1].usage - self.cpu_log[index].usage;
usage_change_sum += usage_change.abs();
let temperature_change =
self.cpu_log[index + 1].temperature - self.cpu_log[index].temperature;
temperature_change_sum += temperature_change.abs();
}
Some(CpuVolatility {
usage: usage_change_sum / change_count as f64,
temperature: temperature_change_sum / change_count as f64,
})
}
fn is_cpu_idle(&self) -> bool {
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 false;
}
let recent_average = self
.cpu_log
.iter()
.rev()
.take(recent_log_count)
.map(|log| log.usage)
.sum::<f64>()
/ recent_log_count as f64;
recent_average < 0.1
&& self
.cpu_volatility()
.is_none_or(|volatility| volatility.usage < 0.05)
}
} }
#[derive(Debug)] #[derive(Debug)]
struct PowerSupplyLog { struct PowerSupplyLog {
at: Instant, at: Instant,
/// Charge 0-1, as a percentage. /// Charge 0-1, as a percentage.
charge: f64, charge: f64,
} }
impl Daemon { impl Daemon {
fn discharging(&self) -> bool { fn discharging(&self) -> bool {
self.system self.system.power_supplies.iter().any(|power_supply| {
.power_supplies power_supply.charge_state.as_deref() == Some("Discharging")
.iter() })
.any(|power_supply| power_supply.charge_state.as_deref() == Some("Discharging")) }
}
/// Calculates the discharge rate, returns a number between 0 and 1. /// Calculates the discharge rate, returns a number between 0 and 1.
/// ///
/// The discharge rate is averaged per hour. /// The discharge rate is averaged per hour.
/// So a return value of Some(0.3) means the battery has been /// So a return value of Some(0.3) means the battery has been
/// discharging 30% per hour. /// discharging 30% per hour.
fn power_supply_discharge_rate(&self) -> Option<f64> { fn power_supply_discharge_rate(&self) -> Option<f64> {
let mut last_charge = None; let mut last_charge = None;
// A list of increasing charge percentages. // A list of increasing charge percentages.
let discharging: Vec<&PowerSupplyLog> = self let discharging: Vec<&PowerSupplyLog> = self
.power_supply_log .power_supply_log
.iter() .iter()
.rev() .rev()
.take_while(move |log| { .take_while(move |log| {
let Some(last_charge_value) = last_charge else { let Some(last_charge_value) = last_charge else {
last_charge = Some(log.charge); last_charge = Some(log.charge);
return true; return true;
};
last_charge = Some(log.charge);
log.charge > last_charge_value
})
.collect();
if discharging.len() < 2 {
return None;
}
// Start of discharging. Has the most charge.
let start = discharging.last().unwrap();
// End of discharging, very close to now. Has the least charge.
let end = discharging.first().unwrap();
let discharging_duration_seconds = (start.at - end.at).as_secs_f64();
let discharging_duration_hours = discharging_duration_seconds / 60.0 / 60.0;
let discharged = start.charge - end.charge;
Some(discharged / discharging_duration_hours)
}
}
impl Daemon {
fn polling_delay(&mut self) -> Duration {
let mut delay = Duration::from_secs(5);
// We are on battery, so we must be more conservative with our polling.
if self.discharging() {
match self.power_supply_discharge_rate() {
Some(discharge_rate) => {
if discharge_rate > 0.2 {
delay *= 3;
} else if discharge_rate > 0.1 {
delay *= 2;
} else {
// *= 1.5;
delay /= 2;
delay *= 3;
}
}
// If we can't determine the discharge rate, that means that
// we were very recently started. Which is user activity.
None => {
delay *= 2;
}
}
}
if self.is_cpu_idle() {
let idle_for = self.last_user_activity.elapsed();
if idle_for > Duration::from_secs(30) {
let factor = idle_multiplier(idle_for);
log::debug!(
"system has been idle for {seconds} seconds (approx {minutes} minutes), applying idle factor: {factor:.2}x",
seconds = idle_for.as_secs(),
minutes = idle_for.as_secs() / 60,
);
delay = Duration::from_secs_f64(delay.as_secs_f64() * factor);
}
}
if let Some(volatility) = self.cpu_volatility() {
if volatility.usage > 0.1 || volatility.temperature > 0.02 {
delay = (delay / 2).max(Duration::from_secs(1));
}
}
let delay = match self.last_polling_delay {
Some(last_delay) => Duration::from_secs_f64(
// 30% of current computed delay, 70% of last delay.
delay.as_secs_f64() * 0.3 + last_delay.as_secs_f64() * 0.7,
),
None => delay,
}; };
let delay = Duration::from_secs_f64(delay.as_secs_f64().clamp(1.0, 30.0)); last_charge = Some(log.charge);
self.last_polling_delay = Some(delay); log.charge > last_charge_value
})
.collect();
delay if discharging.len() < 2 {
return None;
} }
// Start of discharging. Has the most charge.
let start = discharging.last().unwrap();
// End of discharging, very close to now. Has the least charge.
let end = discharging.first().unwrap();
let discharging_duration_seconds = (start.at - end.at).as_secs_f64();
let discharging_duration_hours = discharging_duration_seconds / 60.0 / 60.0;
let discharged = start.charge - end.charge;
Some(discharged / discharging_duration_hours)
}
}
impl Daemon {
fn polling_delay(&mut self) -> Duration {
let mut delay = Duration::from_secs(5);
// We are on battery, so we must be more conservative with our polling.
if self.discharging() {
match self.power_supply_discharge_rate() {
Some(discharge_rate) => {
if discharge_rate > 0.2 {
delay *= 3;
} else if discharge_rate > 0.1 {
delay *= 2;
} else {
// *= 1.5;
delay /= 2;
delay *= 3;
}
},
// If we can't determine the discharge rate, that means that
// we were very recently started. Which is user activity.
None => {
delay *= 2;
},
}
}
if self.is_cpu_idle() {
let idle_for = self.last_user_activity.elapsed();
if idle_for > Duration::from_secs(30) {
let factor = idle_multiplier(idle_for);
log::debug!(
"system has been idle for {seconds} seconds (approx {minutes} \
minutes), applying idle factor: {factor:.2}x",
seconds = idle_for.as_secs(),
minutes = idle_for.as_secs() / 60,
);
delay = Duration::from_secs_f64(delay.as_secs_f64() * factor);
}
}
if let Some(volatility) = self.cpu_volatility() {
if volatility.usage > 0.1 || volatility.temperature > 0.02 {
delay = (delay / 2).max(Duration::from_secs(1));
}
}
let delay = match self.last_polling_delay {
Some(last_delay) => {
Duration::from_secs_f64(
// 30% of current computed delay, 70% of last delay.
delay.as_secs_f64() * 0.3 + last_delay.as_secs_f64() * 0.7,
)
},
None => delay,
};
let delay = Duration::from_secs_f64(delay.as_secs_f64().clamp(1.0, 30.0));
self.last_polling_delay = Some(delay);
delay
}
} }
pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> { pub fn run(config: config::DaemonConfig) -> anyhow::Result<()> {
assert!(config.rules.is_sorted_by_key(|rule| rule.priority)); assert!(config.rules.is_sorted_by_key(|rule| rule.priority));
log::info!("starting daemon..."); log::info!("starting daemon...");
let cancelled = Arc::new(AtomicBool::new(false)); let cancelled = Arc::new(AtomicBool::new(false));
log::debug!("setting ctrl-c handler..."); log::debug!("setting ctrl-c handler...");
let cancelled_ = Arc::clone(&cancelled); let cancelled_ = Arc::clone(&cancelled);
ctrlc::set_handler(move || { ctrlc::set_handler(move || {
log::info!("received shutdown signal"); log::info!("received shutdown signal");
cancelled_.store(true, Ordering::SeqCst); cancelled_.store(true, Ordering::SeqCst);
}) })
.context("failed to set ctrl-c handler")?; .context("failed to set ctrl-c handler")?;
let mut daemon = Daemon { let mut daemon = Daemon {
last_user_activity: Instant::now(), last_user_activity: Instant::now(),
last_polling_delay: None, last_polling_delay: None,
system: system::System::new()?, system: system::System::new()?,
cpu_log: VecDeque::new(), cpu_log: VecDeque::new(),
power_supply_log: VecDeque::new(), power_supply_log: VecDeque::new(),
};
while !cancelled.load(Ordering::SeqCst) {
daemon.rescan()?;
let delay = daemon.polling_delay();
log::info!(
"next poll will be in {seconds} seconds or {minutes} minutes, possibly \
delayed if application of rules takes more than the polling delay",
seconds = delay.as_secs_f64(),
minutes = delay.as_secs_f64() / 60.0,
);
log::debug!("filtering rules and applying them...");
let start = Instant::now();
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(),
}; };
while !cancelled.load(Ordering::SeqCst) { let mut cpu_delta_for = HashMap::<u32, config::CpuDelta>::new();
daemon.rescan()?; let all_cpus =
LazyCell::new(|| (0..num_cpus::get() as u32).collect::<Vec<_>>());
let delay = daemon.polling_delay(); for rule in &config.rules {
log::info!( let Some(condition) = rule.condition.eval(&state)? else {
"next poll will be in {seconds} seconds or {minutes} minutes, possibly delayed if application of rules takes more than the polling delay", continue;
seconds = delay.as_secs_f64(), };
minutes = delay.as_secs_f64() / 60.0,
);
log::debug!("filtering rules and applying them..."); let cpu_for = rule.cpu.for_.as_ref().unwrap_or_else(|| &*all_cpus);
let start = Instant::now(); for cpu in cpu_for {
let delta = cpu_delta_for.entry(*cpu).or_default();
let state = config::EvalState { delta.for_ = Some(vec![*cpu]);
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::<u32, config::CpuDelta>::new(); if let Some(governor) = rule.cpu.governor.as_ref() {
let all_cpus = LazyCell::new(|| (0..num_cpus::get() as u32).collect::<Vec<_>>()); delta.governor = Some(governor.clone());
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() { if let Some(epp) = rule.cpu.energy_performance_preference.as_ref() {
delta.apply()?; delta.energy_performance_preference = Some(epp.clone());
} }
let elapsed = start.elapsed(); if let Some(epb) = rule.cpu.energy_performance_bias.as_ref() {
log::info!( delta.energy_performance_bias = Some(epb.clone());
"filtered and applied rules in {seconds} seconds or {minutes} minutes", }
seconds = elapsed.as_secs_f64(),
minutes = elapsed.as_secs_f64() / 60.0,
);
thread::sleep(delay.saturating_sub(elapsed)); 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()?;
}
} }
log::info!("stopping polling loop and thus daemon..."); for delta in cpu_delta_for.values() {
delta.apply()?;
}
Ok(()) let elapsed = start.elapsed();
log::info!(
"filtered and applied rules in {seconds} seconds or {minutes} minutes",
seconds = elapsed.as_secs_f64(),
minutes = elapsed.as_secs_f64() / 60.0,
);
thread::sleep(delay.saturating_sub(elapsed));
}
log::info!("stopping polling loop and thus daemon...");
Ok(())
} }

View file

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

View file

@ -10,143 +10,149 @@ mod daemon;
// mod engine; // mod engine;
// mod monitor; // mod monitor;
use std::{
fmt::Write as _,
io,
io::Write as _,
path::PathBuf,
process,
};
use anyhow::Context; use anyhow::Context;
use clap::Parser as _; use clap::Parser as _;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::PathBuf;
use std::{io, process};
use yansi::Paint as _; use yansi::Paint as _;
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
#[clap(author, version, about)] #[clap(author, version, about)]
struct Cli { struct Cli {
#[clap(subcommand)] #[clap(subcommand)]
command: Command, command: Command,
} }
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
#[clap(multicall = true)] #[clap(multicall = true)]
enum Command { enum Command {
/// Watt daemon. /// Watt daemon.
Watt { Watt {
#[command(flatten)] #[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity, verbosity: clap_verbosity_flag::Verbosity,
/// The daemon config path. /// The daemon config path.
#[arg(long, env = "WATT_CONFIG")] #[arg(long, env = "WATT_CONFIG")]
config: Option<PathBuf>, config: Option<PathBuf>,
}, },
/// CPU metadata and modification utility. /// CPU metadata and modification utility.
Cpu { Cpu {
#[command(flatten)] #[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity, verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)] #[clap(subcommand)]
command: CpuCommand, command: CpuCommand,
}, },
/// Power supply metadata and modification utility. /// Power supply metadata and modification utility.
Power { Power {
#[command(flatten)] #[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity, verbosity: clap_verbosity_flag::Verbosity,
#[clap(subcommand)] #[clap(subcommand)]
command: PowerCommand, command: PowerCommand,
}, },
} }
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
enum CpuCommand { enum CpuCommand {
/// Modify CPU attributes. /// Modify CPU attributes.
Set(config::CpuDelta), Set(config::CpuDelta),
} }
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
enum PowerCommand { enum PowerCommand {
/// Modify power supply attributes. /// Modify power supply attributes.
Set(config::PowerDelta), Set(config::PowerDelta),
} }
fn real_main() -> anyhow::Result<()> { fn real_main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
yansi::whenever(yansi::Condition::TTY_AND_COLOR); yansi::whenever(yansi::Condition::TTY_AND_COLOR);
let (Command::Watt { verbosity, .. } let (Command::Watt { verbosity, .. }
| Command::Cpu { verbosity, .. } | Command::Cpu { verbosity, .. }
| Command::Power { verbosity, .. }) = cli.command; | Command::Power { verbosity, .. }) = cli.command;
env_logger::Builder::new() env_logger::Builder::new()
.filter_level(verbosity.log_level_filter()) .filter_level(verbosity.log_level_filter())
.format_timestamp(None) .format_timestamp(None)
.format_module_path(false) .format_module_path(false)
.init(); .init();
match cli.command { match cli.command {
Command::Watt { config, .. } => { Command::Watt { config, .. } => {
let config = config::DaemonConfig::load_from(config.as_deref()) let config = config::DaemonConfig::load_from(config.as_deref())
.context("failed to load daemon config")?; .context("failed to load daemon config")?;
daemon::run(config) daemon::run(config)
} },
Command::Cpu { Command::Cpu {
command: CpuCommand::Set(delta), command: CpuCommand::Set(delta),
.. ..
} => delta.apply(), } => delta.apply(),
Command::Power { Command::Power {
command: PowerCommand::Set(delta), command: PowerCommand::Set(delta),
.. ..
} => delta.apply(), } => delta.apply(),
} }
} }
fn main() { fn main() {
let Err(error) = real_main() else { let Err(error) = real_main() else {
return; return;
};
let mut err = io::stderr();
let mut message = String::new();
let mut chain = error.chain().rev().peekable();
while let Some(error) = chain.next() {
let _ = write!(
err,
"{header} ",
header = if chain.peek().is_none() {
"error:"
} else {
"cause:"
}
.red()
.bold(),
);
String::clear(&mut message);
let _ = write!(message, "{error}");
let mut chars = message.char_indices();
let _ = match (chars.next(), chars.next()) {
(Some((_, first)), Some((second_start, second)))
if second.is_lowercase() =>
{
writeln!(
err,
"{first_lowercase}{rest}",
first_lowercase = first.to_lowercase(),
rest = &message[second_start..],
)
},
_ => {
writeln!(err, "{message}")
},
}; };
}
let mut err = io::stderr(); process::exit(1);
let mut message = String::new();
let mut chain = error.chain().rev().peekable();
while let Some(error) = chain.next() {
let _ = write!(
err,
"{header} ",
header = if chain.peek().is_none() {
"error:"
} else {
"cause:"
}
.red()
.bold(),
);
String::clear(&mut message);
let _ = write!(message, "{error}");
let mut chars = message.char_indices();
let _ = match (chars.next(), chars.next()) {
(Some((_, first)), Some((second_start, second))) if second.is_lowercase() => {
writeln!(
err,
"{first_lowercase}{rest}",
first_lowercase = first.to_lowercase(),
rest = &message[second_start..],
)
}
_ => {
writeln!(err, "{message}")
}
};
}
process::exit(1);
} }

View file

@ -1,370 +1,413 @@
use anyhow::{Context, anyhow, bail};
use yansi::Paint as _;
use std::{ use std::{
fmt, fmt,
path::{Path, PathBuf}, path::{
Path,
PathBuf,
},
}; };
use anyhow::{
Context,
anyhow,
bail,
};
use yansi::Paint as _;
use crate::fs; use crate::fs;
/// Represents a pattern of path suffixes used to control charge thresholds /// Represents a pattern of path suffixes used to control charge thresholds
/// for different device vendors. /// for different device vendors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PowerSupplyThresholdConfig { pub struct PowerSupplyThresholdConfig {
pub manufacturer: &'static str, pub manufacturer: &'static str,
pub path_start: &'static str, pub path_start: &'static str,
pub path_end: &'static str, pub path_end: &'static str,
} }
/// Power supply threshold configs. /// Power supply threshold configs.
const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[ const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[
PowerSupplyThresholdConfig { PowerSupplyThresholdConfig {
manufacturer: "Standard", manufacturer: "Standard",
path_start: "charge_control_start_threshold", path_start: "charge_control_start_threshold",
path_end: "charge_control_end_threshold", path_end: "charge_control_end_threshold",
}, },
PowerSupplyThresholdConfig { PowerSupplyThresholdConfig {
manufacturer: "ASUS", manufacturer: "ASUS",
path_start: "charge_control_start_percentage", path_start: "charge_control_start_percentage",
path_end: "charge_control_end_percentage", path_end: "charge_control_end_percentage",
}, },
// Combine Huawei and ThinkPad since they use identical paths. // Combine Huawei and ThinkPad since they use identical paths.
PowerSupplyThresholdConfig { PowerSupplyThresholdConfig {
manufacturer: "ThinkPad/Huawei", manufacturer: "ThinkPad/Huawei",
path_start: "charge_start_threshold", path_start: "charge_start_threshold",
path_end: "charge_stop_threshold", path_end: "charge_stop_threshold",
}, },
// Framework laptop support. // Framework laptop support.
PowerSupplyThresholdConfig { PowerSupplyThresholdConfig {
manufacturer: "Framework", manufacturer: "Framework",
path_start: "charge_behaviour_start_threshold", path_start: "charge_behaviour_start_threshold",
path_end: "charge_behaviour_end_threshold", path_end: "charge_behaviour_end_threshold",
}, },
]; ];
/// Represents a power supply that supports charge threshold control. /// Represents a power supply that supports charge threshold control.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PowerSupply { pub struct PowerSupply {
pub name: String, pub name: String,
pub path: PathBuf, pub path: PathBuf,
pub type_: String, pub type_: String,
pub is_from_peripheral: bool, pub is_from_peripheral: bool,
pub charge_state: Option<String>, pub charge_state: Option<String>,
pub charge_percent: Option<f64>, pub charge_percent: Option<f64>,
pub charge_threshold_start: f64, pub charge_threshold_start: f64,
pub charge_threshold_end: f64, pub charge_threshold_end: f64,
pub drain_rate_watts: Option<f64>, pub drain_rate_watts: Option<f64>,
pub threshold_config: Option<PowerSupplyThresholdConfig>, pub threshold_config: Option<PowerSupplyThresholdConfig>,
} }
impl PowerSupply { impl PowerSupply {
pub fn is_ac(&self) -> bool { pub fn is_ac(&self) -> bool {
!self.is_from_peripheral !self.is_from_peripheral
&& matches!( && matches!(
&*self.type_, &*self.type_,
"Mains" | "USB_PD_DRP" | "USB_PD" | "USB_DCP" | "USB_CDP" | "USB_ACA" "Mains" | "USB_PD_DRP" | "USB_PD" | "USB_DCP" | "USB_CDP" | "USB_ACA"
) )
|| self.type_.starts_with("AC") || self.type_.starts_with("AC")
|| self.type_.contains("ACAD") || self.type_.contains("ACAD")
|| self.type_.contains("ADP") || self.type_.contains("ADP")
} }
} }
impl fmt::Display for PowerSupply { impl fmt::Display for PowerSupply {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "power supply '{name}'", name = self.name.yellow())?; write!(f, "power supply '{name}'", name = self.name.yellow())?;
if let Some(config) = self.threshold_config.as_ref() { if let Some(config) = self.threshold_config.as_ref() {
write!( write!(
f, f,
" from manufacturer '{manufacturer}'", " from manufacturer '{manufacturer}'",
manufacturer = config.manufacturer.green(), manufacturer = config.manufacturer.green(),
)?; )?;
}
Ok(())
} }
Ok(())
}
} }
const POWER_SUPPLY_PATH: &str = "/sys/class/power_supply"; const POWER_SUPPLY_PATH: &str = "/sys/class/power_supply";
impl PowerSupply { impl PowerSupply {
pub fn from_name(name: String) -> anyhow::Result<Self> { pub fn from_name(name: String) -> anyhow::Result<Self> {
let mut power_supply = Self { let mut power_supply = Self {
path: Path::new(POWER_SUPPLY_PATH).join(&name), path: Path::new(POWER_SUPPLY_PATH).join(&name),
name, name,
type_: String::new(), type_: String::new(),
charge_state: None, charge_state: None,
charge_percent: None, charge_percent: None,
charge_threshold_start: 0.0, charge_threshold_start: 0.0,
charge_threshold_end: 1.0, charge_threshold_end: 1.0,
drain_rate_watts: None, drain_rate_watts: None,
is_from_peripheral: false, is_from_peripheral: false,
threshold_config: None, threshold_config: None,
}; };
power_supply.rescan()?; power_supply.rescan()?;
Ok(power_supply) Ok(power_supply)
}
pub fn from_path(path: PathBuf) -> anyhow::Result<Self> {
let mut power_supply = PowerSupply {
name: path
.file_name()
.with_context(|| {
format!("failed to get file name of '{path}'", path = path.display(),)
})?
.to_string_lossy()
.to_string(),
path,
type_: String::new(),
charge_state: None,
charge_percent: None,
charge_threshold_start: 0.0,
charge_threshold_end: 1.0,
drain_rate_watts: None,
is_from_peripheral: false,
threshold_config: None,
};
power_supply.rescan()?;
Ok(power_supply)
}
pub fn all() -> anyhow::Result<Vec<PowerSupply>> {
let mut power_supplies = Vec::new();
for entry in fs::read_dir(POWER_SUPPLY_PATH)
.context("failed to read power supply entries")?
.with_context(|| {
format!("'{POWER_SUPPLY_PATH}' doesn't exist, are you on linux?")
})?
{
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
log::warn!("failed to read power supply entry: {error}");
continue;
},
};
power_supplies.push(PowerSupply::from_path(entry.path())?);
} }
pub fn from_path(path: PathBuf) -> anyhow::Result<Self> { Ok(power_supplies)
let mut power_supply = PowerSupply { }
name: path
.file_name()
.with_context(|| {
format!("failed to get file name of '{path}'", path = path.display(),)
})?
.to_string_lossy()
.to_string(),
path, pub fn rescan(&mut self) -> anyhow::Result<()> {
type_: String::new(), if !self.path.exists() {
bail!("{self} does not exist");
charge_state: None,
charge_percent: None,
charge_threshold_start: 0.0,
charge_threshold_end: 1.0,
drain_rate_watts: None,
is_from_peripheral: false,
threshold_config: None,
};
power_supply.rescan()?;
Ok(power_supply)
} }
pub fn all() -> anyhow::Result<Vec<PowerSupply>> { self.type_ = {
let mut power_supplies = Vec::new(); let type_path = self.path.join("type");
for entry in fs::read_dir(POWER_SUPPLY_PATH) fs::read(&type_path)
.context("failed to read power supply entries")? .with_context(|| {
.with_context(|| format!("'{POWER_SUPPLY_PATH}' doesn't exist, are you on linux?"))? format!("failed to read '{path}'", path = type_path.display())
})?
.with_context(|| {
format!("'{path}' doesn't exist", path = type_path.display())
})?
};
self.is_from_peripheral = 'is_from_peripheral: {
let name_lower = self.name.to_lowercase();
// Common peripheral battery names.
if name_lower.contains("mouse")
|| name_lower.contains("keyboard")
|| name_lower.contains("trackpad")
|| name_lower.contains("gamepad")
|| name_lower.contains("controller")
|| name_lower.contains("headset")
|| name_lower.contains("headphone")
{
break 'is_from_peripheral true;
}
// Small capacity batteries are likely not laptop batteries.
if let Some(energy_full) =
fs::read_n::<u64>(self.path.join("energy_full")).with_context(|| {
format!("failed to read the max charge {self} can hold")
})?
{
// Most laptop batteries are at least 20,000,000 µWh (20 Wh).
// Peripheral batteries are typically much smaller.
if energy_full < 10_000_000 {
// 10 Wh in µWh.
break 'is_from_peripheral true;
}
}
// Check for model name that indicates a peripheral
if let Some(model) = fs::read(self.path.join("model_name"))
.with_context(|| format!("failed to read the model name of {self}"))?
{
if model.contains("bluetooth") || model.contains("wireless") {
break 'is_from_peripheral true;
}
}
false
};
if self.type_ == "Battery" {
self.charge_state = fs::read(self.path.join("status"))
.with_context(|| format!("failed to read {self} charge status"))?;
self.charge_percent = fs::read_n::<u64>(self.path.join("capacity"))
.with_context(|| format!("failed to read {self} charge percent"))?
.map(|percent| percent as f64 / 100.0);
self.charge_threshold_start =
fs::read_n::<u64>(self.path.join("charge_control_start_threshold"))
.with_context(|| {
format!("failed to read {self} charge threshold start")
})?
.map_or(0.0, |percent| percent as f64 / 100.0);
self.charge_threshold_end =
fs::read_n::<u64>(self.path.join("charge_control_end_threshold"))
.with_context(|| {
format!("failed to read {self} charge threshold end")
})?
.map_or(100.0, |percent| percent as f64 / 100.0);
self.drain_rate_watts =
match fs::read_n::<i64>(self.path.join("power_now"))
.with_context(|| format!("failed to read {self} power drain"))?
{ {
let entry = match entry { Some(drain) => Some(drain as f64),
Ok(entry) => entry,
Err(error) => { None => {
log::warn!("failed to read power supply entry: {error}"); let current_ua =
continue; fs::read_n::<i32>(self.path.join("current_now"))
} .with_context(|| format!("failed to read {self} current"))?;
};
power_supplies.push(PowerSupply::from_path(entry.path())?); let voltage_uv =
} fs::read_n::<i32>(self.path.join("voltage_now"))
.with_context(|| format!("failed to read {self} voltage"))?;
Ok(power_supplies) current_ua.zip(voltage_uv).map(|(current, voltage)| {
} // Power (W) = Voltage (V) * Current (A)
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
pub fn rescan(&mut self) -> anyhow::Result<()> { current as f64 * voltage as f64 / 1e12
if !self.path.exists() { })
bail!("{self} does not exist"); },
}
self.type_ = {
let type_path = self.path.join("type");
fs::read(&type_path)
.with_context(|| format!("failed to read '{path}'", path = type_path.display()))?
.with_context(|| format!("'{path}' doesn't exist", path = type_path.display()))?
}; };
self.is_from_peripheral = 'is_from_peripheral: { self.threshold_config = POWER_SUPPLY_THRESHOLD_CONFIGS
let name_lower = self.name.to_lowercase(); .iter()
.find(|config| {
// Common peripheral battery names. self.path.join(config.path_start).exists()
if name_lower.contains("mouse") && self.path.join(config.path_end).exists()
|| name_lower.contains("keyboard") })
|| name_lower.contains("trackpad") .copied();
|| name_lower.contains("gamepad")
|| name_lower.contains("controller")
|| name_lower.contains("headset")
|| name_lower.contains("headphone")
{
break 'is_from_peripheral true;
}
// Small capacity batteries are likely not laptop batteries.
if let Some(energy_full) = fs::read_n::<u64>(self.path.join("energy_full"))
.with_context(|| format!("failed to read the max charge {self} can hold"))?
{
// Most laptop batteries are at least 20,000,000 µWh (20 Wh).
// Peripheral batteries are typically much smaller.
if energy_full < 10_000_000 {
// 10 Wh in µWh.
break 'is_from_peripheral true;
}
}
// Check for model name that indicates a peripheral
if let Some(model) = fs::read(self.path.join("model_name"))
.with_context(|| format!("failed to read the model name of {self}"))?
{
if model.contains("bluetooth") || model.contains("wireless") {
break 'is_from_peripheral true;
}
}
false
};
if self.type_ == "Battery" {
self.charge_state = fs::read(self.path.join("status"))
.with_context(|| format!("failed to read {self} charge status"))?;
self.charge_percent = fs::read_n::<u64>(self.path.join("capacity"))
.with_context(|| format!("failed to read {self} charge percent"))?
.map(|percent| percent as f64 / 100.0);
self.charge_threshold_start =
fs::read_n::<u64>(self.path.join("charge_control_start_threshold"))
.with_context(|| format!("failed to read {self} charge threshold start"))?
.map_or(0.0, |percent| percent as f64 / 100.0);
self.charge_threshold_end =
fs::read_n::<u64>(self.path.join("charge_control_end_threshold"))
.with_context(|| format!("failed to read {self} charge threshold end"))?
.map_or(100.0, |percent| percent as f64 / 100.0);
self.drain_rate_watts = match fs::read_n::<i64>(self.path.join("power_now"))
.with_context(|| format!("failed to read {self} power drain"))?
{
Some(drain) => Some(drain as f64),
None => {
let current_ua = fs::read_n::<i32>(self.path.join("current_now"))
.with_context(|| format!("failed to read {self} current"))?;
let voltage_uv = fs::read_n::<i32>(self.path.join("voltage_now"))
.with_context(|| format!("failed to read {self} voltage"))?;
current_ua.zip(voltage_uv).map(|(current, voltage)| {
// Power (W) = Voltage (V) * Current (A)
// (v / 1e6 V) * (c / 1e6 A) = (v * c / 1e12) W
current as f64 * voltage as f64 / 1e12
})
}
};
self.threshold_config = POWER_SUPPLY_THRESHOLD_CONFIGS
.iter()
.find(|config| {
self.path.join(config.path_start).exists()
&& self.path.join(config.path_end).exists()
})
.copied();
}
Ok(())
} }
pub fn charge_threshold_path_start(&self) -> Option<PathBuf> { Ok(())
self.threshold_config }
.map(|config| self.path.join(config.path_start))
}
pub fn charge_threshold_path_end(&self) -> Option<PathBuf> { pub fn charge_threshold_path_start(&self) -> Option<PathBuf> {
self.threshold_config self
.map(|config| self.path.join(config.path_end)) .threshold_config
} .map(|config| self.path.join(config.path_start))
}
pub fn set_charge_threshold_start( pub fn charge_threshold_path_end(&self) -> Option<PathBuf> {
&mut self, self
charge_threshold_start: f64, .threshold_config
) -> anyhow::Result<()> { .map(|config| self.path.join(config.path_end))
fs::write( }
&self.charge_threshold_path_start().ok_or_else(|| {
anyhow!( pub fn set_charge_threshold_start(
"power supply '{name}' does not support changing charge threshold levels", &mut self,
name = self.name, charge_threshold_start: f64,
) ) -> anyhow::Result<()> {
})?, fs::write(
&((charge_threshold_start * 100.0) as u8).to_string(), &self.charge_threshold_path_start().ok_or_else(|| {
anyhow!(
"power supply '{name}' does not support changing charge threshold \
levels",
name = self.name,
) )
.with_context(|| format!("failed to set charge threshold start for {self}"))?; })?,
&((charge_threshold_start * 100.0) as u8).to_string(),
)
.with_context(|| {
format!("failed to set charge threshold start for {self}")
})?;
self.charge_threshold_start = charge_threshold_start; self.charge_threshold_start = charge_threshold_start;
log::info!("set battery threshold start for {self} to {charge_threshold_start}%"); log::info!(
"set battery threshold start for {self} to {charge_threshold_start}%"
);
Ok(()) Ok(())
} }
pub fn set_charge_threshold_end(&mut self, charge_threshold_end: f64) -> anyhow::Result<()> { pub fn set_charge_threshold_end(
fs::write( &mut self,
&self.charge_threshold_path_end().ok_or_else(|| { charge_threshold_end: f64,
anyhow!( ) -> anyhow::Result<()> {
"power supply '{name}' does not support changing charge threshold levels", fs::write(
name = self.name, &self.charge_threshold_path_end().ok_or_else(|| {
) anyhow!(
})?, "power supply '{name}' does not support changing charge threshold \
&((charge_threshold_end * 100.0) as u8).to_string(), levels",
name = self.name,
) )
.with_context(|| format!("failed to set charge threshold end for {self}"))?; })?,
&((charge_threshold_end * 100.0) as u8).to_string(),
)
.with_context(|| {
format!("failed to set charge threshold end for {self}")
})?;
self.charge_threshold_end = charge_threshold_end; self.charge_threshold_end = charge_threshold_end;
log::info!("set battery threshold end for {self} to {charge_threshold_end}%"); log::info!(
"set battery threshold end for {self} to {charge_threshold_end}%"
);
Ok(()) Ok(())
}
pub fn get_available_platform_profiles() -> anyhow::Result<Vec<String>> {
let path = "/sys/firmware/acpi/platform_profile_choices";
let Some(content) = fs::read(path)
.context("failed to read available ACPI platform profiles")?
else {
return Ok(Vec::new());
};
Ok(
content
.split_whitespace()
.map(ToString::to_string)
.collect(),
)
}
/// Sets the platform profile.
/// This changes the system performance, temperature, fan, and other hardware
/// related characteristics.
///
/// Also see [`The Kernel docs`] for this.
///
/// [`The Kernel docs`]: <https://docs.kernel.org/userspace-api/sysfs-platform_profile.html>
pub fn set_platform_profile(profile: &str) -> anyhow::Result<()> {
let profiles = Self::get_available_platform_profiles()?;
if !profiles
.iter()
.any(|avail_profile| avail_profile == profile)
{
bail!(
"profile '{profile}' is not available for system. valid profiles: \
{profiles}",
profiles = profiles.join(", "),
);
} }
pub fn get_available_platform_profiles() -> anyhow::Result<Vec<String>> { fs::write("/sys/firmware/acpi/platform_profile", profile).context(
let path = "/sys/firmware/acpi/platform_profile_choices"; "this probably means that your system does not support changing ACPI \
profiles",
)
}
let Some(content) = pub fn platform_profile() -> anyhow::Result<String> {
fs::read(path).context("failed to read available ACPI platform profiles")? fs::read("/sys/firmware/acpi/platform_profile")
else { .context("failed to read platform profile")?
return Ok(Vec::new()); .context("failed to find platform profile")
}; }
Ok(content
.split_whitespace()
.map(ToString::to_string)
.collect())
}
/// Sets the platform profile.
/// This changes the system performance, temperature, fan, and other hardware replated characteristics.
///
/// Also see [`The Kernel docs`] for this.
///
/// [`The Kernel docs`]: <https://docs.kernel.org/userspace-api/sysfs-platform_profile.html>
pub fn set_platform_profile(profile: &str) -> anyhow::Result<()> {
let profiles = Self::get_available_platform_profiles()?;
if !profiles
.iter()
.any(|avail_profile| avail_profile == profile)
{
bail!(
"profile '{profile}' is not available for system. valid profiles: {profiles}",
profiles = profiles.join(", "),
);
}
fs::write("/sys/firmware/acpi/platform_profile", profile)
.context("this probably means that your system does not support changing ACPI profiles")
}
pub fn platform_profile() -> anyhow::Result<String> {
fs::read("/sys/firmware/acpi/platform_profile")
.context("failed to read platform profile")?
.context("failed to find platform profile")
}
} }

View file

@ -1,383 +1,413 @@
use std::{collections::HashMap, path::Path, time::Instant}; use std::{
collections::HashMap,
path::Path,
time::Instant,
};
use anyhow::{Context, bail}; use anyhow::{
Context,
bail,
};
use crate::{cpu, fs, power_supply}; use crate::{
cpu,
fs,
power_supply,
};
#[derive(Debug)] #[derive(Debug)]
pub struct System { pub struct System {
pub is_ac: bool, pub is_ac: bool,
pub load_average_1min: f64, pub load_average_1min: f64,
pub load_average_5min: f64, pub load_average_5min: f64,
pub load_average_15min: f64, pub load_average_15min: f64,
pub cpus: Vec<cpu::Cpu>, pub cpus: Vec<cpu::Cpu>,
pub cpu_temperatures: HashMap<u32, f64>, pub cpu_temperatures: HashMap<u32, f64>,
pub power_supplies: Vec<power_supply::PowerSupply>, pub power_supplies: Vec<power_supply::PowerSupply>,
} }
impl System { impl System {
pub fn new() -> anyhow::Result<Self> { pub fn new() -> anyhow::Result<Self> {
let mut system = Self { let mut system = Self {
is_ac: false, is_ac: false,
cpus: Vec::new(), cpus: Vec::new(),
cpu_temperatures: HashMap::new(), cpu_temperatures: HashMap::new(),
power_supplies: Vec::new(), power_supplies: Vec::new(),
load_average_1min: 0.0, load_average_1min: 0.0,
load_average_5min: 0.0, load_average_5min: 0.0,
load_average_15min: 0.0, load_average_15min: 0.0,
};
system.rescan()?;
Ok(system)
}
pub fn rescan(&mut self) -> anyhow::Result<()> {
log::debug!("rescanning view of system hardware...");
{
let start = Instant::now();
self.cpus = cpu::Cpu::all().context("failed to scan CPUs")?;
log::debug!(
"rescanned all CPUs in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
{
let start = Instant::now();
self.power_supplies = power_supply::PowerSupply::all()
.context("failed to scan power supplies")?;
log::debug!(
"rescanned all power supplies in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
self.is_ac = self
.power_supplies
.iter()
.any(|power_supply| power_supply.is_ac())
|| {
log::debug!(
"checking whether if this device is a desktop to determine if it is \
AC as no power supplies are"
);
let start = Instant::now();
let is_desktop = self.is_desktop()?;
log::debug!(
"checked if is a desktop in {millis}ms",
millis = start.elapsed().as_millis(),
);
log::debug!(
"scan result: {elaborate}",
elaborate = if is_desktop {
"is a desktop, therefore is AC"
} else {
"not a desktop, and not AC"
},
);
is_desktop
};
{
let start = Instant::now();
self.rescan_load_average()?;
log::debug!(
"rescanned load average in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
{
let start = Instant::now();
self.rescan_temperatures()?;
log::debug!(
"rescanned temperatures in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
Ok(())
}
fn rescan_temperatures(&mut self) -> anyhow::Result<()> {
const PATH: &str = "/sys/class/hwmon";
let mut temperatures = HashMap::new();
for entry in fs::read_dir(PATH)
.context("failed to read hardware information")?
.with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))?
{
let entry =
entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
let entry_path = entry.path();
let Some(name) =
fs::read(entry_path.join("name")).with_context(|| {
format!(
"failed to read name of hardware entry at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
match &*name {
// TODO: 'zenergy' can also report those stats, I think?
"coretemp" | "k10temp" | "zenpower" | "amdgpu" => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
},
// Other CPU temperature drivers.
_ if name.contains("cpu") || name.contains("temp") => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
},
_ => {},
}
}
if temperatures.is_empty() {
const PATH: &str = "/sys/devices/virtual/thermal";
log::debug!(
"failed to get CPU temperature information by using hwmon, falling \
back to '{PATH}'"
);
let Some(thermal_zones) =
fs::read_dir(PATH).context("failed to read thermal information")?
else {
return Ok(());
};
let mut counter = 0;
for entry in thermal_zones {
let entry =
entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
let entry_path = entry.path();
let entry_name = entry.file_name();
let entry_name = entry_name.to_string_lossy();
if !entry_name.starts_with("thermal_zone") {
continue;
}
let Some(entry_type) =
fs::read(entry_path.join("type")).with_context(|| {
format!(
"failed to read type of zone at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
}; };
system.rescan()?; if !entry_type.contains("cpu")
&& !entry_type.contains("x86")
&& !entry_type.contains("core")
{
continue;
}
Ok(system) let Some(temperature_mc) = fs::read_n::<i64>(entry_path.join("temp"))
.with_context(|| {
format!(
"failed to read temperature of zone at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
// Magic value to see that it is from the thermal zones.
temperatures.insert(777 + counter, temperature_mc as f64 / 1000.0);
counter += 1;
}
} }
pub fn rescan(&mut self) -> anyhow::Result<()> { self.cpu_temperatures = temperatures;
log::debug!("rescanning view of system hardware...");
{ Ok(())
let start = Instant::now(); }
self.cpus = cpu::Cpu::all().context("failed to scan CPUs")?;
log::debug!(
"rescanned all CPUs in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
{ fn get_temperatures(
let start = Instant::now(); device_path: &Path,
self.power_supplies = temperatures: &mut HashMap<u32, f64>,
power_supply::PowerSupply::all().context("failed to scan power supplies")?; ) -> anyhow::Result<()> {
log::debug!( // Increased range to handle systems with many sensors.
"rescanned all power supplies in {millis}ms", for i in 1..=96 {
millis = start.elapsed().as_millis(), let label_path = device_path.join(format!("temp{i}_label"));
); let input_path = device_path.join(format!("temp{i}_input"));
}
self.is_ac = self if !label_path.exists() || !input_path.exists() {
.power_supplies log::debug!(
.iter() "{label_path} or {input_path} doesn't exist, skipping temp label",
.any(|power_supply| power_supply.is_ac()) label_path = label_path.display(),
|| { input_path = input_path.display(),
log::debug!( );
"checking whether if this device is a desktop to determine if it is AC as no power supplies are" continue;
); }
let start = Instant::now(); log::debug!(
let is_desktop = self.is_desktop()?; "{label_path} or {input_path} exists, scanning temp label...",
log::debug!( label_path = label_path.display(),
"checked if is a desktop in {millis}ms", input_path = input_path.display(),
millis = start.elapsed().as_millis(), );
);
log::debug!( let Some(label) = fs::read(&label_path).with_context(|| {
"scan result: {elaborate}", format!(
elaborate = if is_desktop { "failed to read hardware hardware device label from '{path}'",
"is a desktop, therefore is AC" path = label_path.display(),
} else { )
"not a desktop, and not AC" })?
}, else {
); continue;
};
log::debug!("label content: {label}");
is_desktop // Match various common label formats:
}; // "Core X", "core X", "Core-X", "CPU Core X", etc.
let number = label
.trim_start_matches("cpu")
.trim_start_matches("CPU")
.trim_start()
.trim_start_matches("core")
.trim_start_matches("Core")
.trim_start()
.trim_start_matches("Tctl")
.trim_start_matches("Tdie")
.trim_start_matches("Tccd")
.trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
.trim_start()
.trim_start_matches("-")
.trim();
{ log::debug!(
let start = Instant::now(); "stripped 'Core' or similar identifier prefix of label content: \
self.rescan_load_average()?; {number}"
log::debug!( );
"rescanned load average in {millis}ms",
millis = start.elapsed().as_millis(),
);
}
{ let Ok(number) = number.parse::<u32>() else {
let start = Instant::now(); log::debug!("stripped content not a valid number, skipping");
self.rescan_temperatures()?; continue;
log::debug!( };
"rescanned temperatures in {millis}ms", log::debug!(
millis = start.elapsed().as_millis(), "stripped content is a valid number, taking it as the core number"
); );
} log::debug!(
Ok(())
}
fn rescan_temperatures(&mut self) -> anyhow::Result<()> {
const PATH: &str = "/sys/class/hwmon";
let mut temperatures = HashMap::new();
for entry in fs::read_dir(PATH)
.context("failed to read hardware information")?
.with_context(|| format!("'{PATH}' doesn't exist, are you on linux?"))?
{
let entry = entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
let entry_path = entry.path();
let Some(name) = fs::read(entry_path.join("name")).with_context(|| {
format!(
"failed to read name of hardware entry at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
match &*name {
// TODO: 'zenergy' can also report those stats, I think?
"coretemp" | "k10temp" | "zenpower" | "amdgpu" => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
}
// Other CPU temperature drivers.
_ if name.contains("cpu") || name.contains("temp") => {
Self::get_temperatures(&entry_path, &mut temperatures)?;
}
_ => {}
}
}
if temperatures.is_empty() {
const PATH: &str = "/sys/devices/virtual/thermal";
log::debug!(
"failed to get CPU temperature information by using hwmon, falling back to '{PATH}'"
);
let Some(thermal_zones) =
fs::read_dir(PATH).context("failed to read thermal information")?
else {
return Ok(());
};
let mut counter = 0;
for entry in thermal_zones {
let entry = entry.with_context(|| format!("failed to read entry of '{PATH}'"))?;
let entry_path = entry.path();
let entry_name = entry.file_name();
let entry_name = entry_name.to_string_lossy();
if !entry_name.starts_with("thermal_zone") {
continue;
}
let Some(entry_type) = fs::read(entry_path.join("type")).with_context(|| {
format!(
"failed to read type of zone at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
if !entry_type.contains("cpu")
&& !entry_type.contains("x86")
&& !entry_type.contains("core")
{
continue;
}
let Some(temperature_mc) = fs::read_n::<i64>(entry_path.join("temp"))
.with_context(|| {
format!(
"failed to read temperature of zone at '{path}'",
path = entry_path.display(),
)
})?
else {
continue;
};
// Magic value to see that it is from the thermal zones.
temperatures.insert(777 + counter, temperature_mc as f64 / 1000.0);
counter += 1;
}
}
self.cpu_temperatures = temperatures;
Ok(())
}
fn get_temperatures(
device_path: &Path,
temperatures: &mut HashMap<u32, f64>,
) -> anyhow::Result<()> {
// Increased range to handle systems with many sensors.
for i in 1..=96 {
let label_path = device_path.join(format!("temp{i}_label"));
let input_path = device_path.join(format!("temp{i}_input"));
if !label_path.exists() || !input_path.exists() {
log::debug!(
"{label_path} or {input_path} doesn't exist, skipping temp label",
label_path = label_path.display(),
input_path = input_path.display(),
);
continue;
}
log::debug!(
"{label_path} or {input_path} exists, scanning temp label...",
label_path = label_path.display(),
input_path = input_path.display(),
);
let Some(label) = fs::read(&label_path).with_context(|| {
format!(
"failed to read hardware hardware device label from '{path}'",
path = label_path.display(),
)
})?
else {
continue;
};
log::debug!("label content: {label}");
// Match various common label formats:
// "Core X", "core X", "Core-X", "CPU Core X", etc.
let number = label
.trim_start_matches("cpu")
.trim_start_matches("CPU")
.trim_start()
.trim_start_matches("core")
.trim_start_matches("Core")
.trim_start()
.trim_start_matches("Tctl")
.trim_start_matches("Tdie")
.trim_start_matches("Tccd")
.trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
.trim_start()
.trim_start_matches("-")
.trim();
log::debug!("stripped 'Core' or similar identifier prefix of label content: {number}");
let Ok(number) = number.parse::<u32>() else {
log::debug!("stripped content not a valid number, skipping");
continue;
};
log::debug!("stripped content is a valid number, taking it as the core number");
log::debug!(
"it is fine if this number doesn't seem accurate due to CPU binning, see a more detailed explanation at: https://rgbcu.be/blog/why-cores" "it is fine if this number doesn't seem accurate due to CPU binning, see a more detailed explanation at: https://rgbcu.be/blog/why-cores"
); );
let Some(temperature_mc) = fs::read_n::<i64>(&input_path).with_context(|| { let Some(temperature_mc) =
format!( fs::read_n::<i64>(&input_path).with_context(|| {
"failed to read CPU temperature from '{path}'", format!(
path = input_path.display(), "failed to read CPU temperature from '{path}'",
) path = input_path.display(),
})? )
else { })?
continue; else {
}; continue;
log::debug!( };
"temperature content: {celcius} celcius", log::debug!(
celcius = temperature_mc as f64 / 1000.0 "temperature content: {celsius} celsius",
); celsius = temperature_mc as f64 / 1000.0
);
temperatures.insert(number, temperature_mc as f64 / 1000.0); temperatures.insert(number, temperature_mc as f64 / 1000.0);
}
Ok(())
} }
fn is_desktop(&mut self) -> anyhow::Result<bool> { Ok(())
log::debug!("checking chassis type to determine if we are a desktop"); }
if let Some(chassis_type) =
fs::read("/sys/class/dmi/id/chassis_type").context("failed to read chassis type")?
{
// 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower,
// 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In One,
// 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main Server Chassis,
// 31=Convertible Laptop
match chassis_type.trim() {
// Desktop form factors.
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => {
log::debug!("chassis is a desktop form factor, short circuting true");
return Ok(true);
}
// Laptop form factors. fn is_desktop(&mut self) -> anyhow::Result<bool> {
"9" | "10" | "14" | "31" => { log::debug!("checking chassis type to determine if we are a desktop");
log::debug!("chassis is a laptop form factor, short circuting false"); if let Some(chassis_type) = fs::read("/sys/class/dmi/id/chassis_type")
return Ok(false); .context("failed to read chassis type")?
} {
// 3=Desktop, 4=Low Profile Desktop, 5=Pizza Box, 6=Mini Tower,
// 7=Tower, 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 13=All In
// One, 14=Sub Notebook, 15=Space-saving, 16=Lunch Box, 17=Main
// Server Chassis, 31=Convertible Laptop
match chassis_type.trim() {
// Desktop form factors.
"3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => {
log::debug!("chassis is a desktop form factor, short circuting true");
return Ok(true);
},
// Unknown, continue with other checks // Laptop form factors.
_ => log::debug!("unknown chassis type"), "9" | "10" | "14" | "31" => {
} log::debug!("chassis is a laptop form factor, short circuting false");
} return Ok(false);
},
// Check battery-specific ACPI paths that laptops typically have // Unknown, continue with other checks
let laptop_acpi_paths = [ _ => log::debug!("unknown chassis type"),
"/sys/class/power_supply/BAT0", }
"/sys/class/power_supply/BAT1",
"/proc/acpi/battery",
];
log::debug!("checking existence of ACPI paths");
for path in laptop_acpi_paths {
if fs::exists(path) {
log::debug!("path '{path}' exists, short circuting false");
return Ok(false); // Likely a laptop.
}
}
log::debug!("checking if power saving paths exists");
// Check CPU power policies, desktops often don't have these
let power_saving_exists = fs::exists("/sys/module/intel_pstate/parameters/no_hwp")
|| fs::exists("/sys/devices/system/cpu/cpufreq/conservative");
if !power_saving_exists {
log::debug!("power saving paths do not exist, short circuting true");
return Ok(true); // Likely a desktop.
}
// Default to assuming desktop if we can't determine.
log::debug!("cannot determine whether if we are a desktop, defaulting to true");
Ok(true)
} }
fn rescan_load_average(&mut self) -> anyhow::Result<()> { // Check battery-specific ACPI paths that laptops typically have
let content = fs::read("/proc/loadavg") let laptop_acpi_paths = [
.context("failed to read load average from '/proc/loadavg'")? "/sys/class/power_supply/BAT0",
.context("'/proc/loadavg' doesn't exist, are you on linux?")?; "/sys/class/power_supply/BAT1",
"/proc/acpi/battery",
];
let mut parts = content.split_whitespace(); log::debug!("checking existence of ACPI paths");
for path in laptop_acpi_paths {
let (Some(load_average_1min), Some(load_average_5min), Some(load_average_15min)) = if fs::exists(path) {
(parts.next(), parts.next(), parts.next()) log::debug!("path '{path}' exists, short circuting false");
else { return Ok(false); // Likely a laptop.
bail!( }
"failed to parse first 3 load average entries due to there not being enough, content: {content}"
);
};
self.load_average_1min = load_average_1min
.parse()
.context("failed to parse load average")?;
self.load_average_5min = load_average_5min
.parse()
.context("failed to parse load average")?;
self.load_average_15min = load_average_15min
.parse()
.context("failed to parse load average")?;
Ok(())
} }
log::debug!("checking if power saving paths exists");
// Check CPU power policies, desktops often don't have these
let power_saving_exists =
fs::exists("/sys/module/intel_pstate/parameters/no_hwp")
|| fs::exists("/sys/devices/system/cpu/cpufreq/conservative");
if !power_saving_exists {
log::debug!("power saving paths do not exist, short circuting true");
return Ok(true); // Likely a desktop.
}
// Default to assuming desktop if we can't determine.
log::debug!(
"cannot determine whether if we are a desktop, defaulting to true"
);
Ok(true)
}
fn rescan_load_average(&mut self) -> anyhow::Result<()> {
let content = fs::read("/proc/loadavg")
.context("failed to read load average from '/proc/loadavg'")?
.context("'/proc/loadavg' doesn't exist, are you on linux?")?;
let mut parts = content.split_whitespace();
let (
Some(load_average_1min),
Some(load_average_5min),
Some(load_average_15min),
) = (parts.next(), parts.next(), parts.next())
else {
bail!(
"failed to parse first 3 load average entries due to there not being \
enough, content: {content}"
);
};
self.load_average_1min = load_average_1min
.parse()
.context("failed to parse load average")?;
self.load_average_5min = load_average_5min
.parse()
.context("failed to parse load average")?;
self.load_average_15min = load_average_15min
.parse()
.context("failed to parse load average")?;
Ok(())
}
} }