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