diff --git a/Cargo.lock b/Cargo.lock index f077741..2b0446d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,12 +261,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "hermit-abi" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" - [[package]] name = "indexmap" version = "2.9.0" @@ -277,17 +271,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "is-terminal" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" -dependencies = [ - "hermit-abi 0.5.1", - "libc", - "windows-sys", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -381,7 +364,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] @@ -714,6 +697,3 @@ name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -dependencies = [ - "is-terminal", -] diff --git a/Cargo.toml b/Cargo.toml index aeecd4b..287929e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,5 +19,5 @@ thiserror = "2.0" anyhow = "1.0" jiff = "0.2.13" clap-verbosity-flag = "3.0.2" -yansi = { version = "1.0.1", features = ["detect-env", "detect-tty"] } +yansi = "1.0.1" derive_more = { version = "2.0.1", features = ["full"] } diff --git a/src/core.rs b/src/core.rs index a3f4e33..07581aa 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,6 +1,8 @@ pub struct SystemInfo { // Overall system details pub cpu_model: String, + pub architecture: String, + pub linux_distribution: String, } pub struct CpuCoreInfo { diff --git a/src/cpu.rs b/src/cpu.rs index 736008a..0179746 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -5,37 +5,10 @@ use std::{fmt, string::ToString}; use crate::fs; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy)] pub struct Cpu { pub number: u32, - pub has_cpufreq: bool, - - pub time_user: u64, - pub time_nice: u64, - pub time_system: u64, - pub time_idle: u64, - pub time_iowait: u64, - pub time_irq: u64, - pub time_softirq: u64, - pub time_steal: u64, -} - -impl Cpu { - pub fn time_total(&self) -> u64 { - self.time_user - + self.time_nice - + self.time_system - + self.time_idle - + self.time_iowait - + self.time_irq - + self.time_softirq - + self.time_steal - } - - pub fn time_idle(&self) -> u64 { - self.time_idle + self.time_iowait - } } impl fmt::Display for Cpu { @@ -51,15 +24,6 @@ impl Cpu { let mut cpu = Self { number, has_cpufreq: false, - - time_user: 0, - time_nice: 0, - time_system: 0, - time_idle: 0, - time_iowait: 0, - time_irq: 0, - time_softirq: 0, - time_steal: 0, }; cpu.rescan()?; @@ -112,70 +76,9 @@ impl Cpu { bail!("{self} does not exist"); } - self.has_cpufreq = fs::exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); + let has_cpufreq = fs::exists(format!("/sys/devices/system/cpu/cpu{number}/cpufreq")); - self.rescan_times()?; - - Ok(()) - } - - fn rescan_times(&mut self) -> anyhow::Result<()> { - // TODO: Don't read this per CPU. Share the read or - // find something in /sys/.../cpu{N} that does it. - let content = fs::read("/proc/stat") - .context("/proc/stat does not exist")? - .context("failed to read CPU stat")?; - - let cpu_name = format!("cpu{number}", number = self.number); - - let mut stats = content - .lines() - .find_map(|line| { - line.starts_with(&cpu_name) - .then(|| line.split_whitespace().skip(1)) - }) - .with_context(|| format!("failed to find {self} in CPU stats"))?; - - self.time_user = stats - .next() - .with_context(|| format!("failed to find {self} user time"))? - .parse() - .with_context(|| format!("failed to parse {self} user time"))?; - self.time_nice = stats - .next() - .with_context(|| format!("failed to find {self} nice time"))? - .parse() - .with_context(|| format!("failed to parse {self} nice time"))?; - self.time_system = stats - .next() - .with_context(|| format!("failed to find {self} system time"))? - .parse() - .with_context(|| format!("failed to parse {self} system time"))?; - self.time_idle = stats - .next() - .with_context(|| format!("failed to find {self} idle time"))? - .parse() - .with_context(|| format!("failed to parse {self} idle time"))?; - self.time_iowait = stats - .next() - .with_context(|| format!("failed to find {self} iowait time"))? - .parse() - .with_context(|| format!("failed to parse {self} iowait time"))?; - self.time_irq = stats - .next() - .with_context(|| format!("failed to find {self} irq time"))? - .parse() - .with_context(|| format!("failed to parse {self} irq time"))?; - self.time_softirq = stats - .next() - .with_context(|| format!("failed to find {self} softirq time"))? - .parse() - .with_context(|| format!("failed to parse {self} softirq time"))?; - self.time_steal = stats - .next() - .with_context(|| format!("failed to find {self} steal time"))? - .parse() - .with_context(|| format!("failed to parse {self} steal time"))?; + self.has_cpufreq = has_cpufreq; Ok(()) } @@ -329,7 +232,7 @@ impl Cpu { fn validate_frequency_minimum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { let Self { number, .. } = self; - let Some(Ok(minimum_frequency_khz)) = fs::read_u64(format!( + let Ok(minimum_frequency_khz) = fs::read_u64(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" )) else { // Just let it pass if we can't find anything. @@ -367,7 +270,7 @@ impl Cpu { fn validate_frequency_maximum(&self, new_frequency_mhz: u64) -> anyhow::Result<()> { let Self { number, .. } = self; - let Some(Ok(maximum_frequency_khz)) = fs::read_u64(format!( + let Ok(maximum_frequency_khz) = fs::read_u64(format!( "/sys/devices/system/cpu/cpu{number}/cpufreq/scaling_min_freq" )) else { // Just let it pass if we can't find anything. diff --git a/src/fs.rs b/src/fs.rs index 9b150b3..b1d1c71 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -29,19 +29,18 @@ pub fn read(path: impl AsRef) -> Option> { } } -pub fn read_u64(path: impl AsRef) -> Option> { +pub fn read_u64(path: impl AsRef) -> anyhow::Result { let path = path.as_ref(); - match read(path)? { - Ok(content) => Some(content.trim().parse().with_context(|| { - format!( - "failed to parse contents of '{path}' as a unsigned number", - path = path.display(), - ) - })), + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read '{path}'", path = path.display()))?; - Err(error) => Some(Err(error)), - } + Ok(content.trim().parse().with_context(|| { + format!( + "failed to parse contents of '{path}' as a unsigned number", + path = path.display(), + ) + })?) } pub fn write(path: impl AsRef, value: &str) -> anyhow::Result<()> { diff --git a/src/main.rs b/src/main.rs index e435cee..825465d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ enum Command { /// Start the daemon. Start { /// The daemon config path. - #[arg(long, env = "WATT_CONFIG")] + #[arg(long, env = "SUPERFREQ_CONFIG")] config: PathBuf, }, @@ -50,8 +50,6 @@ enum Command { fn real_main() -> anyhow::Result<()> { let cli = Cli::parse(); - yansi::whenever(yansi::Condition::TTY_AND_COLOR); - env_logger::Builder::new() .filter_level(cli.verbosity.log_level_filter()) .format_timestamp(None) diff --git a/src/monitor.rs b/src/monitor.rs index 5d0468b..79d2635 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -10,10 +10,142 @@ use std::{ time::SystemTime, }; +// Read a sysfs file to a string, trimming whitespace +fn read_sysfs_file_trimmed(path: impl AsRef) -> anyhow::Result { + fs::read_to_string(path.as_ref()) + .map(|s| s.trim().to_string()) + .map_err(|e| { + SysMonitorError::ReadError(format!("Path: {:?}, Error: {}", path.as_ref().display(), e)) + }) +} + +// Read a sysfs file and parse it to a specific type +fn read_sysfs_value(path: impl AsRef) -> anyhow::Result { + let content = read_sysfs_file_trimmed(path.as_ref())?; + content.parse::().map_err(|_| { + SysMonitorError::ParseError(format!( + "Could not parse '{}' from {:?}", + content, + path.as_ref().display() + )) + }) +} + pub fn get_system_info() -> SystemInfo { let cpu_model = get_cpu_model().unwrap_or_else(|_| "Unknown".to_string()); + let linux_distribution = get_linux_distribution().unwrap_or_else(|_| "Unknown".to_string()); + let architecture = std::env::consts::ARCH.to_string(); - SystemInfo { cpu_model } + SystemInfo { + cpu_model, + architecture, + linux_distribution, + } +} + +#[derive(Debug, Clone, Copy)] +pub struct CpuTimes { + user: u64, + nice: u64, + system: u64, + idle: u64, + iowait: u64, + irq: u64, + softirq: u64, + steal: u64, +} + +impl CpuTimes { + const fn total_time(&self) -> u64 { + self.user + + self.nice + + self.system + + self.idle + + self.iowait + + self.irq + + self.softirq + + self.steal + } + + const fn idle_time(&self) -> u64 { + self.idle + self.iowait + } +} + +fn read_all_cpu_times() -> anyhow::Result> { + let content = fs::read_to_string("/proc/stat").map_err(SysMonitorError::Io)?; + let mut cpu_times_map = HashMap::new(); + + for line in content.lines() { + if line.starts_with("cpu") && line.chars().nth(3).is_some_and(|c| c.is_ascii_digit()) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 11 { + return Err(SysMonitorError::ProcStatParseError(format!( + "Line too short: {line}" + ))); + } + + let core_id_str = &parts[0][3..]; + let core_id = core_id_str.parse::().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse core_id: {core_id_str}" + )) + })?; + + let times = CpuTimes { + user: parts[1].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse user time: {}", + parts[1] + )) + })?, + nice: parts[2].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse nice time: {}", + parts[2] + )) + })?, + system: parts[3].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse system time: {}", + parts[3] + )) + })?, + idle: parts[4].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse idle time: {}", + parts[4] + )) + })?, + iowait: parts[5].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse iowait time: {}", + parts[5] + )) + })?, + irq: parts[6].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse irq time: {}", + parts[6] + )) + })?, + softirq: parts[7].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse softirq time: {}", + parts[7] + )) + })?, + steal: parts[8].parse().map_err(|_| { + SysMonitorError::ProcStatParseError(format!( + "Failed to parse steal time: {}", + parts[8] + )) + })?, + }; + cpu_times_map.insert(core_id, times); + } + } + Ok(cpu_times_map) } pub fn get_cpu_core_info( @@ -467,6 +599,110 @@ pub fn get_battery_info(config: &AppConfig) -> anyhow::Result> Ok(batteries) } +/// Check if a battery is likely a peripheral (mouse, keyboard, etc) not a laptop battery +fn is_peripheral_battery(ps_path: &Path, name: &str) -> bool { + // Convert name to lowercase once for case-insensitive matching + let name_lower = 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") + { + return true; + } + + // Small capacity batteries are likely not laptop batteries + if let Ok(energy_full) = read_sysfs_value::(ps_path.join("energy_full")) { + // 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 + return true; + } + } + + // Check for model name that indicates a peripheral + if let Ok(model) = read_sysfs_file_trimmed(ps_path.join("model_name")) { + if model.contains("bluetooth") || model.contains("wireless") { + return true; + } + } + + false +} + +/// Determine if this is likely a desktop system rather than a laptop +fn is_likely_desktop_system() -> bool { + // Check for DMI system type information + if let Ok(chassis_type) = fs::read_to_string("/sys/class/dmi/id/chassis_type") { + let chassis_type = chassis_type.trim(); + + // Chassis types: + // 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 + match chassis_type { + "3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => return true, // desktop form factors + "9" | "10" | "14" => return false, // laptop form factors + _ => {} // Unknown, continue with other checks + } + } + + // Check CPU power policies, desktops often don't have these + let power_saving_exists = Path::new("/sys/module/intel_pstate/parameters/no_hwp").exists() + || Path::new("/sys/devices/system/cpu/cpufreq/conservative").exists(); + + if !power_saving_exists { + return true; // likely a desktop + } + + // Check battery-specific ACPI paths that laptops typically have + let laptop_acpi_paths = [ + "/sys/class/power_supply/BAT0", + "/sys/class/power_supply/BAT1", + "/proc/acpi/battery", + ]; + + for path in &laptop_acpi_paths { + if Path::new(path).exists() { + return false; // Likely a laptop + } + } + + // Default to assuming desktop if we can't determine + true +} + +pub fn get_system_load() -> anyhow::Result { + let loadavg_str = read_sysfs_file_trimmed("/proc/loadavg")?; + let parts: Vec<&str> = loadavg_str.split_whitespace().collect(); + if parts.len() < 3 { + return Err(SysMonitorError::ParseError( + "Could not parse /proc/loadavg: expected at least 3 parts".to_string(), + )); + } + let load_avg_1min = parts[0].parse().map_err(|_| { + SysMonitorError::ParseError(format!("Failed to parse 1min load: {}", parts[0])) + })?; + let load_avg_5min = parts[1].parse().map_err(|_| { + SysMonitorError::ParseError(format!("Failed to parse 5min load: {}", parts[1])) + })?; + let load_avg_15min = parts[2].parse().map_err(|_| { + SysMonitorError::ParseError(format!("Failed to parse 15min load: {}", parts[2])) + })?; + + Ok(SystemLoad { + load_avg_1min, + load_avg_5min, + load_avg_15min, + }) +} + pub fn collect_system_report(config: &AppConfig) -> anyhow::Result { let system_info = get_system_info(); let cpu_cores = get_all_cpu_core_info()?; @@ -502,3 +738,45 @@ pub fn get_cpu_model() -> anyhow::Result { "Could not find CPU model name in /proc/cpuinfo.".to_string(), )) } + +pub fn get_linux_distribution() -> anyhow::Result { + let os_release_path = Path::new("/etc/os-release"); + let content = fs::read_to_string(os_release_path).map_err(|_| { + SysMonitorError::ReadError(format!( + "Cannot read contents of {}.", + os_release_path.display() + )) + })?; + + for line in content.lines() { + if line.starts_with("PRETTY_NAME=") { + if let Some(val) = line.split('=').nth(1) { + let linux_distribution = val.trim_matches('"').to_string(); + return Ok(linux_distribution); + } + } + } + + let lsb_release_path = Path::new("/etc/lsb-release"); + let content = fs::read_to_string(lsb_release_path).map_err(|_| { + SysMonitorError::ReadError(format!( + "Cannot read contents of {}.", + lsb_release_path.display() + )) + })?; + + for line in content.lines() { + if line.starts_with("DISTRIB_DESCRIPTION=") { + if let Some(val) = line.split('=').nth(1) { + let linux_distribution = val.trim_matches('"').to_string(); + return Ok(linux_distribution); + } + } + } + + Err(SysMonitorError::ParseError(format!( + "Could not find distribution name in {} or {}.", + os_release_path.display(), + lsb_release_path.display() + ))) +} diff --git a/src/power_supply.rs b/src/power_supply.rs index 5bbcebc..f1dcb41 100644 --- a/src/power_supply.rs +++ b/src/power_supply.rs @@ -48,9 +48,6 @@ const POWER_SUPPLY_THRESHOLD_CONFIGS: &[PowerSupplyThresholdConfig] = &[ pub struct PowerSupply { pub name: String, pub path: PathBuf, - - pub is_from_peripheral: bool, - pub threshold_config: Option, } @@ -77,9 +74,6 @@ impl PowerSupply { let mut power_supply = Self { path: Path::new(POWER_SUPPLY_PATH).join(&name), name, - - is_from_peripheral: false, - threshold_config: None, }; @@ -100,8 +94,6 @@ impl PowerSupply { path, - is_from_peripheral: false, - threshold_config: None, }; @@ -146,7 +138,7 @@ impl PowerSupply { bail!("{self} does not exist"); } - self.threshold_config = self + let threshold_config = self .get_type() .with_context(|| format!("failed to determine what type of power supply '{self}' is"))? .eq("Battery") @@ -163,45 +155,7 @@ impl PowerSupply { }) .flatten(); - 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_u64(self.path.join("energy_full")) { - let energy_full = 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")) { - let model = - model.with_context(|| format!("failed to read the model name of '{self}'"))?; - - if model.contains("bluetooth") || model.contains("wireless") { - break 'is_from_peripheral true; - } - } - - false - }; + self.threshold_config = threshold_config; Ok(()) } diff --git a/src/system.rs b/src/system.rs index f8820b1..1d3e697 100644 --- a/src/system.rs +++ b/src/system.rs @@ -1,114 +1,14 @@ -use anyhow::{Context, bail}; - -use crate::fs; - pub struct System { pub is_desktop: bool, - - pub load_average_1min: f64, - pub load_average_5min: f64, - pub load_average_15min: f64, } impl System { pub fn new() -> anyhow::Result { - let mut system = Self { - is_desktop: false, - - load_average_1min: 0.0, - load_average_5min: 0.0, - load_average_15min: 0.0, - }; - + let mut system = Self { is_desktop: false }; system.rescan()?; Ok(system) } - pub fn rescan(&mut self) -> anyhow::Result<()> { - self.rescan_is_desktop()?; - self.rescan_load_average()?; - - Ok(()) - } - - fn rescan_is_desktop(&mut self) -> anyhow::Result<()> { - if let Some(chassis_type) = fs::read("/sys/class/dmi/id/chassis_type") { - let chassis_type = 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 - match chassis_type.trim() { - // Desktop form factors. - "3" | "4" | "5" | "6" | "7" | "15" | "16" | "17" => { - self.is_desktop = true; - return Ok(()); - } - // Laptop form factors. - "9" | "10" | "14" => { - self.is_desktop = false; - return Ok(()); - } - - // Unknown, continue with other checks - _ => {} - } - } - - // 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 { - self.is_desktop = true; - return Ok(()); // Likely a desktop. - } - - // Check battery-specific ACPI paths that laptops typically have - let laptop_acpi_paths = [ - "/sys/class/power_supply/BAT0", - "/sys/class/power_supply/BAT1", - "/proc/acpi/battery", - ]; - - for path in laptop_acpi_paths { - if fs::exists(path) { - self.is_desktop = false; // Likely a laptop. - return Ok(()); - } - } - - // Default to assuming desktop if we can't determine. - self.is_desktop = true; - Ok(()) - } - - fn rescan_load_average(&mut self) -> anyhow::Result<()> { - let content = fs::read("/proc/loadavg") - .context("load average file doesn't exist, are you on linux?")? - .context("failed to read load average")?; - - 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(()) - } + pub fn rescan(&mut self) -> anyhow::Result<()> {} }