diff --git a/src/daemon.rs b/src/daemon.rs index ea70257..fc47de8 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -83,6 +83,9 @@ fn handle_request(module_name: &str, args: &[&str], state: &SharedState, config: "pool" | "btrfs" => crate::modules::btrfs::BtrfsModule.run(config, state, args), "vol" => crate::modules::audio::AudioModule.run(config, state, &["sink", args.get(0).unwrap_or(&"show")]), "mic" => crate::modules::audio::AudioModule.run(config, state, &["source", args.get(0).unwrap_or(&"show")]), + "gpu" => crate::modules::gpu::GpuModule.run(config, state, args), + "sys" => crate::modules::sys::SysModule.run(config, state, args), + "bt" | "bluetooth" => crate::modules::bt::BtModule.run(config, state, args), _ => { warn!("Received request for unknown module: '{}'", module_name); Err(anyhow::anyhow!("Unknown module: {}", module_name)) diff --git a/src/main.rs b/src/main.rs index 7bf5280..2446625 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,16 @@ enum Commands { #[arg(short, long)] cycle: bool, }, + /// GPU usage, VRAM, and temp module + Gpu, + /// System load average and uptime + Sys, + /// Bluetooth audio device status + #[command(alias = "bluetooth")] + Bt { + #[arg(default_value = "show")] + action: String, + }, } fn main() { @@ -95,6 +105,15 @@ fn main() { let action = if *cycle { "cycle" } else { "show" }; handle_ipc_response(ipc::request_data("mic", &[action.to_string()])); } + Commands::Gpu => { + handle_ipc_response(ipc::request_data("gpu", &[])); + } + Commands::Sys => { + handle_ipc_response(ipc::request_data("sys", &[])); + } + Commands::Bt { action } => { + handle_ipc_response(ipc::request_data("bt", &[action.clone()])); + } } } diff --git a/src/modules/audio.rs b/src/modules/audio.rs index 7e2d880..fb0ea6c 100644 --- a/src/modules/audio.rs +++ b/src/modules/audio.rs @@ -114,7 +114,7 @@ impl AudioModule { let output = Command::new("pactl").args(["list", "short", list_cmd]).output()?; let stdout = String::from_utf8_lossy(&output.stdout); - let mut devices: Vec = stdout.lines() + let devices: Vec = stdout.lines() .filter_map(|l| { let parts: Vec<&str> = l.split_whitespace().collect(); if parts.len() >= 2 { diff --git a/src/modules/bt.rs b/src/modules/bt.rs new file mode 100644 index 0000000..6f763e2 --- /dev/null +++ b/src/modules/bt.rs @@ -0,0 +1,117 @@ +use crate::config::Config; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::SharedState; +use anyhow::Result; +use std::process::Command; + +pub struct BtModule; + +impl WaybarModule for BtModule { + fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result { + let action = args.first().unwrap_or(&"show"); + + if *action == "disconnect" { + if let Some(mac) = find_audio_device() { + let _ = Command::new("bluetoothctl").args(["disconnect", &mac]).output(); + } + return Ok(WaybarOutput { + text: String::new(), + tooltip: None, + class: None, + percentage: None, + }); + } + + // Check if bluetooth is powered on + if let Ok(output) = Command::new("bluetoothctl").arg("show").output() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("Powered: no") { + return Ok(WaybarOutput { + text: "󰂲 Off".to_string(), + tooltip: Some("Bluetooth Disabled".to_string()), + class: Some("disabled".to_string()), + percentage: None, + }); + } + } + + if let Some(mac) = find_audio_device() { + let info_output = Command::new("bluetoothctl").args(["info", &mac]).output()?; + let info = String::from_utf8_lossy(&info_output.stdout); + + let mut alias = mac.clone(); + let mut battery = None; + let mut trusted = "no"; + + for line in info.lines() { + if line.contains("Alias:") { + alias = line.split("Alias:").nth(1).unwrap_or("").trim().to_string(); + } else if line.contains("Battery Percentage:") { + if let Some(bat_str) = line.split('(').nth(1).and_then(|s| s.split(')').next()) { + battery = bat_str.parse::().ok(); + } + } else if line.contains("Trusted: yes") { + trusted = "yes"; + } + } + + let tooltip = format!( + "{} | MAC: {}\nTrusted: {} | Bat: {}", + alias, + mac, + trusted, + battery.map(|b| format!("{}%", b)).unwrap_or_else(|| "N/A".to_string()) + ); + + Ok(WaybarOutput { + text: format!("{} 󰂰", alias), + tooltip: Some(tooltip), + class: Some("connected".to_string()), + percentage: battery, + }) + } else { + // No device connected but Bluetooth is on + Ok(WaybarOutput { + text: "󰂯".to_string(), + tooltip: Some("Bluetooth On (Disconnected)".to_string()), + class: Some("disconnected".to_string()), + percentage: None, + }) + } + } +} + +fn find_audio_device() -> Option { + // 1. Try to check if current default sink is a bluetooth device + if let Ok(output) = Command::new("pactl").arg("get-default-sink").output() { + let sink = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sink.starts_with("bluez_output.") { + let parts: Vec<&str> = sink.split('.').collect(); + if parts.len() >= 2 { + return Some(parts[1].replace('_', ":")); + } + } + } + + // 2. Fallback: Search bluetoothctl for connected devices with Audio Sink UUID + if let Ok(output) = Command::new("bluetoothctl").args(["devices", "Connected"]).output() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.starts_with("Device ") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let mac = parts[1]; + if let Ok(info) = Command::new("bluetoothctl").args(["info", mac]).output() { + let info_str = String::from_utf8_lossy(&info.stdout); + if info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") { // Audio Sink UUID + return Some(mac.to_string()); + } + } + } + } + } + } + + None +} diff --git a/src/modules/gpu.rs b/src/modules/gpu.rs new file mode 100644 index 0000000..a6c7a7c --- /dev/null +++ b/src/modules/gpu.rs @@ -0,0 +1,64 @@ +use crate::config::Config; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::SharedState; +use anyhow::Result; + +pub struct GpuModule; + +impl WaybarModule for GpuModule { + fn run(&self, _config: &Config, state: &SharedState, _args: &[&str]) -> Result { + let (active, vendor, usage, vram_used, vram_total, temp, model) = { + if let Ok(state_lock) = state.read() { + ( + state_lock.gpu.active, + state_lock.gpu.vendor.clone(), + state_lock.gpu.usage, + state_lock.gpu.vram_used, + state_lock.gpu.vram_total, + state_lock.gpu.temp, + state_lock.gpu.model.clone(), + ) + } else { + (false, String::from("Unknown"), 0.0, 0.0, 0.0, 0.0, String::from("Unknown")) + } + }; + + if !active { + return Ok(WaybarOutput { + text: "No GPU".to_string(), + tooltip: None, + class: Some("normal".to_string()), + percentage: None, + }); + } + + let class = if usage > 95.0 { + "max" + } else if usage > 75.0 { + "high" + } else { + "normal" + }; + + let text = if vendor == "Intel" { + // Intel usually doesn't expose easy VRAM or Temp without root + format!("iGPU: {:.0}%", usage) + } else { + format!("{}: {:.0}% {:.1}/{:.1}GB {:.1}C", vendor, usage, vram_used, vram_total, temp) + }; + + let tooltip = if vendor == "Intel" { + format!("Model: {}\nApprox Usage: {:.0}%", model, usage) + } else { + format!("Model: {}\nUsage: {:.0}%\nVRAM: {:.1}/{:.1}GB\nTemp: {:.1}°C", model, usage, vram_used, vram_total, temp) + }; + + Ok(WaybarOutput { + text, + tooltip: Some(tooltip), + class: Some(class.to_string()), + percentage: Some(usage as u8), + }) + } +} diff --git a/src/modules/hardware.rs b/src/modules/hardware.rs index ad6be6f..94d3fec 100644 --- a/src/modules/hardware.rs +++ b/src/modules/hardware.rs @@ -17,13 +17,13 @@ impl HardwareDaemon { pub fn poll(&mut self, state: SharedState) { self.sys.refresh_cpu_usage(); self.sys.refresh_memory(); + self.sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); self.components.refresh(true); let cpu_usage = self.sys.global_cpu_usage(); let cpu_model = self.sys.cpus().first().map(|c| c.brand().to_string()).unwrap_or_else(|| "Unknown".to_string()); // Try to find a reasonable CPU temperature - // Often 'coretemp' or 'k10temp' depending on AMD/Intel let mut cpu_temp = 0.0; for component in &self.components { let label = component.label().to_lowercase(); @@ -36,10 +36,13 @@ impl HardwareDaemon { } let total_mem = self.sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0; - // Accurate used memory matching htop/free (Total - Available) let available_mem = self.sys.available_memory() as f64 / 1024.0 / 1024.0 / 1024.0; let used_mem = total_mem - available_mem; + let load_avg = System::load_average(); + let uptime = System::uptime(); + let process_count = self.sys.processes().len(); + if let Ok(mut state_lock) = state.write() { state_lock.cpu.usage = cpu_usage as f64; state_lock.cpu.temp = cpu_temp as f64; @@ -47,6 +50,104 @@ impl HardwareDaemon { state_lock.memory.total_gb = total_mem; state_lock.memory.used_gb = used_mem; + + state_lock.sys.load_1 = load_avg.one; + state_lock.sys.load_5 = load_avg.five; + state_lock.sys.load_15 = load_avg.fifteen; + state_lock.sys.uptime = uptime; + state_lock.sys.process_count = process_count; + + self.poll_gpu(&mut state_lock.gpu); + } + } + + fn poll_gpu(&self, gpu: &mut crate::state::GpuState) { + gpu.active = false; + + // 1. Try NVIDIA via nvidia-smi + if let Ok(output) = std::process::Command::new("nvidia-smi") + .args(["--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name", "--format=csv,noheader,nounits"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(line) = stdout.lines().next() { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() >= 5 { + gpu.active = true; + gpu.vendor = "NVIDIA".to_string(); + gpu.usage = parts[0].trim().parse().unwrap_or(0.0); + gpu.vram_used = parts[1].trim().parse::().unwrap_or(0.0) / 1024.0; // from MB to GB + gpu.vram_total = parts[2].trim().parse::().unwrap_or(0.0) / 1024.0; + gpu.temp = parts[3].trim().parse().unwrap_or(0.0); + gpu.model = parts[4].trim().to_string(); + return; + } + } + } + } + + // Iterate over sysfs for AMD or Intel + for i in 0..=3 { + let base = format!("/sys/class/drm/card{}/device", i); + + // 2. Try AMD + if let Ok(usage_str) = std::fs::read_to_string(format!("{}/gpu_busy_percent", base)) { + gpu.active = true; + gpu.vendor = "AMD".to_string(); + gpu.usage = usage_str.trim().parse().unwrap_or(0.0); + + if let Ok(mem_used) = std::fs::read_to_string(format!("{}/mem_info_vram_used", base)) { + gpu.vram_used = mem_used.trim().parse::().unwrap_or(0.0) / 1024.0 / 1024.0 / 1024.0; + } + if let Ok(mem_total) = std::fs::read_to_string(format!("{}/mem_info_vram_total", base)) { + gpu.vram_total = mem_total.trim().parse::().unwrap_or(0.0) / 1024.0 / 1024.0 / 1024.0; + } + + if let Ok(entries) = std::fs::read_dir(format!("{}/hwmon", base)) { + for entry in entries.flatten() { + let temp_path = entry.path().join("temp1_input"); + if let Ok(temp_str) = std::fs::read_to_string(temp_path) { + gpu.temp = temp_str.trim().parse::().unwrap_or(0.0) / 1000.0; + break; + } + } + } + gpu.model = "AMD GPU".to_string(); + return; + } + + // 3. Try Intel (iGPU) + let freq_path = if std::path::Path::new(&format!("{}/gt_cur_freq_mhz", base)).exists() { + Some(format!("{}/gt_cur_freq_mhz", base)) + } else if std::path::Path::new(&format!("/sys/class/drm/card{}/gt_cur_freq_mhz", i)).exists() { + Some(format!("/sys/class/drm/card{}/gt_cur_freq_mhz", i)) + } else { + None + }; + + if let Some(path) = freq_path { + if let Ok(freq_str) = std::fs::read_to_string(&path) { + gpu.active = true; + gpu.vendor = "Intel".to_string(); + + let cur_freq = freq_str.trim().parse::().unwrap_or(0.0); + let mut max_freq = 0.0; + + let max_path = path.replace("gt_cur_freq_mhz", "gt_max_freq_mhz"); + if let Ok(max_str) = std::fs::read_to_string(max_path) { + max_freq = max_str.trim().parse::().unwrap_or(0.0); + } + + // Approximate usage via frequency scaling + gpu.usage = if max_freq > 0.0 { (cur_freq / max_freq) * 100.0 } else { 0.0 }; + gpu.temp = 0.0; // Intel iGPU temps are usually tied to CPU package temp + gpu.vram_used = 0.0; // iGPU shares system memory + gpu.vram_total = 0.0; + gpu.model = format!("Intel iGPU ({}MHz)", cur_freq); + return; + } + } } } } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 687f27a..331fb18 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -5,6 +5,9 @@ pub mod hardware; pub mod disk; pub mod btrfs; pub mod audio; +pub mod gpu; +pub mod sys; +pub mod bt; use crate::config::Config; use crate::output::WaybarOutput; diff --git a/src/modules/sys.rs b/src/modules/sys.rs new file mode 100644 index 0000000..bfd4858 --- /dev/null +++ b/src/modules/sys.rs @@ -0,0 +1,43 @@ +use crate::config::Config; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::SharedState; +use anyhow::Result; + +pub struct SysModule; + +impl WaybarModule for SysModule { + fn run(&self, _config: &Config, state: &SharedState, _args: &[&str]) -> Result { + let (load1, load5, load15, uptime_secs, process_count) = { + if let Ok(state_lock) = state.read() { + ( + state_lock.sys.load_1, + state_lock.sys.load_5, + state_lock.sys.load_15, + state_lock.sys.uptime, + state_lock.sys.process_count, + ) + } else { + (0.0, 0.0, 0.0, 0, 0) + } + }; + + let hours = uptime_secs / 3600; + let minutes = (uptime_secs % 3600) / 60; + let uptime_str = if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + }; + + Ok(WaybarOutput { + text: format!("UP: {} | LOAD: {:.2} {:.2} {:.2}", uptime_str, load1, load5, load15), + tooltip: Some(format!( + "Uptime: {}\nProcesses: {}\nLoad Avg: {:.2}, {:.2}, {:.2}", + uptime_str, process_count, load1, load5, load15 + )), + class: Some("normal".to_string()), + percentage: None, + }) + } +} diff --git a/src/state.rs b/src/state.rs index c49a571..2d84b81 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,6 +5,8 @@ pub struct AppState { pub network: NetworkState, pub cpu: CpuState, pub memory: MemoryState, + pub sys: SysState, + pub gpu: GpuState, } #[derive(Default, Clone)] @@ -36,4 +38,38 @@ pub struct MemoryState { pub total_gb: f64, } +#[derive(Default, Clone)] +pub struct SysState { + pub load_1: f64, + pub load_5: f64, + pub load_15: f64, + pub uptime: u64, + pub process_count: usize, +} + +#[derive(Clone)] +pub struct GpuState { + pub active: bool, + pub vendor: String, + pub usage: f64, + pub vram_used: f64, + pub vram_total: f64, + pub temp: f64, + pub model: String, +} + +impl Default for GpuState { + fn default() -> Self { + Self { + active: false, + vendor: String::from("Unknown"), + usage: 0.0, + vram_used: 0.0, + vram_total: 0.0, + temp: 0.0, + model: String::from("Unknown"), + } + } +} + pub type SharedState = Arc>;