diff --git a/README.md b/README.md index ee2f8c7..b12119f 100644 --- a/README.md +++ b/README.md @@ -5,50 +5,57 @@ fluxo-rs is a high-performance system metrics daemon and client designed specifi ## description the project follows a client-server architecture: -- daemon: handles heavy lifting (polling cpu, memory, network) and stores state in memory. +- daemon: handles heavy lifting (polling cpu, memory, network, gpu) and stores state in memory. - client: a thin cli wrapper that connects to the daemon's socket to retrieve formatted json for waybar. this approach eliminates process spawning overhead and temporary file locking, resulting in near-zero cpu usage for custom modules. +## features + +- ultra-lightweight: background polling is highly optimized (e.g., O(1) process counting). +- jitter-free: uses zero-width sentinels and figure spaces to prevent waybar from trimming padding. +- configurable: customizable output formats via toml config. +- live reload: configuration can be reloaded without restarting the daemon. +- multi-vendor gpu: native support for intel (igpu), amd, and nvidia. + ## modules -- net: network interface speed (rx/tx mb/s) -- cpu: global usage percentage and package temperature -- mem: used/total ram in gigabytes -- disk: disk usage for a specific mountpoint -- pool: aggregate storage usage (e.g., btrfs) -- vol: audio output volume and device management -- mic: audio input volume and device management - -## dependencies - -### system -- iproute2 (for network interface discovery) -- wireplumber (for volume and mute status via wpctl) -- pulseaudio (for device description and cycling via pactl) -- lm-sensors (recommended for cpu temperature) - -### rust -- cargo / rustc (edition 2024) +| command | description | tokens | +| :--- | :--- | :--- | +| `net` | network speed (rx/tx) | `{interface}`, `{ip}`, `{rx}`, `{tx}` | +| `cpu` | cpu usage and temp | `{usage}`, `{temp}` | +| `mem` | memory usage | `{used}`, `{total}` | +| `gpu` | gpu utilization | `{usage}`, `{vram_used}`, `{vram_total}`, `{temp}` | +| `sys` | system load and uptime | `{uptime}`, `{load1}`, `{load5}`, `{load15}` | +| `disk` | disk usage (default: /) | `{mount}`, `{used}`, `{total}` | +| `pool` | aggregate storage (btrfs) | `{used}`, `{total}` | +| `vol` | audio output volume | `{percentage}`, `{icon}` | +| `mic` | audio input volume | `{percentage}`, `{icon}` | +| `bt` | bluetooth status | device name and battery | +| `buds` | pixel buds pro control | left/right battery and anc state | +| `power` | battery and ac status | `{percentage}`, `{icon}` | +| `game` | hyprland gamemode status | active/inactive icon | ## setup 1. build the project: - ``` - $ git clone https://git.narl.io/nvrl/fluxo-rs - $ cd fluxo-rs - $ cargo build --release + ```bash + cd fluxo-rs + cargo build --release ``` 2. start the daemon: - ``` - $ ./target/release/fluxo-rs daemon & + ```bash + ./target/release/fluxo-rs daemon & ``` -3. configure waybar (config.jsonc): - ``` +3. configuration: + create `~/.config/fluxo/config.toml` (see `example.config.toml` for all options). + +4. waybar configuration (`config.jsonc`): + ```json "custom/cpu": { - "exec": "/path/to/fluxo-rs cpu", + "exec": "~/path/to/fluxo-rs cpu", "return-type": "json" } ``` @@ -56,25 +63,26 @@ this approach eliminates process spawning overhead and temporary file locking, r ## development ### architecture -- src/main.rs: entry point and cli argument parsing -- src/daemon.rs: uds listener and background thread orchestration -- src/ipc.rs: thin client socket communication -- src/modules/: individual metric implementation logic -- src/state.rs: shared in-memory data structures +- `src/main.rs`: entry point, cli parsing, and client-side formatting logic. +- `src/daemon.rs`:uds listener, configuration management, and polling orchestration. +- `src/ipc.rs`: unix domain socket communication logic. +- `src/modules/`: individual metric implementations. +- `src/state.rs`: shared thread-safe data structures. ### adding a module -1. define the state structure in state.rs -2. implement the waybarmodule trait in src/modules/ -3. add the polling logic to the background thread in daemon.rs -4. register the subcommand in main.rs +1. add the required fields to `src/state.rs`. +2. implement the `WaybarModule` trait in a new file in `src/modules/`. +3. add polling logic to `src/modules/hardware.rs` or `src/daemon.rs`. +4. register the new subcommand in `src/main.rs` and the router in `src/daemon.rs`. -### build and debug -build for release: - ``` - $ cargo build --release - ``` +### configuration reload +the daemon can reload its configuration live: +```bash +fluxo-rs reload +``` -run with debug logs: - ``` - $ RUST_LOG=debug ./target/release/fluxo-rs daemon - ``` +### logs +run the daemon with debug logs for troubleshooting: +```bash +RUST_LOG=debug fluxo-rs daemon +``` diff --git a/src/config.rs b/src/config.rs index 2b9f12d..1088a85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,8 @@ pub struct Config { pub pool: PoolConfig, #[serde(default)] pub power: PowerConfig, + #[serde(default)] + pub buds: BudsConfig, } #[derive(Deserialize)] @@ -130,6 +132,19 @@ impl Default for PowerConfig { } } +#[derive(Deserialize)] +pub struct BudsConfig { + pub mac: String, +} + +impl Default for BudsConfig { + fn default() -> Self { + Self { + mac: "B4:23:A2:09:D3:53".to_string(), + } + } +} + pub fn load_config() -> Config { let config_dir = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) diff --git a/src/daemon.rs b/src/daemon.rs index d817123..6084e1e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -21,8 +21,7 @@ pub fn run_daemon() -> Result<()> { let state: SharedState = Arc::new(RwLock::new(AppState::default())); let listener = UnixListener::bind(SOCKET_PATH)?; - let config = crate::config::load_config(); - let config = Arc::new(config); + let config = Arc::new(RwLock::new(crate::config::load_config())); // Spawn the background polling thread let poll_state = Arc::clone(&state); @@ -57,6 +56,16 @@ pub fn run_daemon() -> Result<()> { let parts: Vec<&str> = request.split_whitespace().collect(); if let Some(module_name) = parts.first() { + if *module_name == "reload" { + info!("Reloading configuration..."); + let new_config = crate::config::load_config(); + if let Ok(mut config_lock) = config_clone.write() { + *config_lock = new_config; + let _ = stream.write_all(b"{\"text\":\"ok\"}"); + } + return; + } + debug!(module = module_name, args = ?&parts[1..], "Handling IPC request"); let response = handle_request(*module_name, &parts[1..], &state_clone, &config_clone); if let Err(e) = stream.write_all(response.as_bytes()) { @@ -72,23 +81,30 @@ pub fn run_daemon() -> Result<()> { Ok(()) } -fn handle_request(module_name: &str, args: &[&str], state: &SharedState, config: &Config) -> String { +fn handle_request(module_name: &str, args: &[&str], state: &SharedState, config_lock: &Arc>) -> String { debug!(module = module_name, args = ?args, "Handling request"); + let config = if let Ok(c) = config_lock.read() { + c + } else { + // Fallback to default if lock fails (should not happen normally) + return "{\"text\":\"error: config lock failed\"}".to_string(); + }; + let result = match module_name { - "net" | "network" => crate::modules::network::NetworkModule.run(config, state, args), - "cpu" => crate::modules::cpu::CpuModule.run(config, state, args), - "mem" | "memory" => crate::modules::memory::MemoryModule.run(config, state, args), - "disk" => crate::modules::disk::DiskModule.run(config, state, args), - "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), - "buds" => crate::modules::buds::BudsModule.run(config, state, args), - "power" => crate::modules::power::PowerModule.run(config, state, args), - "game" => crate::modules::game::GameModule.run(config, state, args), + "net" | "network" => crate::modules::network::NetworkModule.run(&config, state, args), + "cpu" => crate::modules::cpu::CpuModule.run(&config, state, args), + "mem" | "memory" => crate::modules::memory::MemoryModule.run(&config, state, args), + "disk" => crate::modules::disk::DiskModule.run(&config, state, args), + "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), + "buds" => crate::modules::buds::BudsModule.run(&config, state, args), + "power" => crate::modules::power::PowerModule.run(&config, state, args), + "game" => crate::modules::game::GameModule.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 a030d15..13bfb46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,8 @@ struct Cli { enum Commands { /// Start the background polling daemon Daemon, + /// Reload the daemon configuration + Reload, /// Network speed module #[command(alias = "network")] Net, @@ -90,6 +92,15 @@ fn main() { process::exit(1); } } + Commands::Reload => { + match ipc::request_data("reload", &[]) { + Ok(_) => info!("Reload signal sent to daemon"), + Err(e) => { + error!("Failed to send reload signal: {}", e); + process::exit(1); + } + } + } Commands::Net => handle_ipc_response(ipc::request_data("net", &[])), Commands::Cpu => handle_ipc_response(ipc::request_data("cpu", &[])), Commands::Mem => handle_ipc_response(ipc::request_data("mem", &[])), @@ -118,8 +129,6 @@ fn handle_ipc_response(response: anyhow::Result) { match serde_json::from_str::(&json_str) { Ok(mut val) => { if let Some(text) = val.get_mut("text").and_then(|t| t.as_str()) { - // 1. Replace regular spaces with Figure Spaces (\u2007) for perfect numeric alignment - // 2. Wrap the text in Zero-Width Spaces (\u200B) to prevent Waybar from trimming let fixed_text = format!("\u{200B}{}\u{200B}", text.replace(' ', "\u{2007}")); val["text"] = serde_json::Value::String(fixed_text); } diff --git a/src/modules/buds.rs b/src/modules/buds.rs index 8470e97..6d2fff7 100644 --- a/src/modules/buds.rs +++ b/src/modules/buds.rs @@ -7,11 +7,10 @@ use std::process::Command; pub struct BudsModule; -const MAC_ADDRESS: &str = "B4:23:A2:09:D3:53"; - impl WaybarModule for BudsModule { - fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result { + fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { let action = args.first().unwrap_or(&"show"); + let mac = &config.buds.mac; match *action { "cycle_anc" => { @@ -33,7 +32,7 @@ impl WaybarModule for BudsModule { }); } "connect" => { - Command::new("bluetoothctl").args(["connect", MAC_ADDRESS]).status()?; + Command::new("bluetoothctl").args(["connect", mac]).status()?; return Ok(WaybarOutput { text: String::new(), tooltip: None, @@ -42,7 +41,7 @@ impl WaybarModule for BudsModule { }); } "disconnect" => { - Command::new("bluetoothctl").args(["disconnect", MAC_ADDRESS]).status()?; + Command::new("bluetoothctl").args(["disconnect", mac]).status()?; return Ok(WaybarOutput { text: String::new(), tooltip: None, @@ -54,7 +53,7 @@ impl WaybarModule for BudsModule { } // Check if connected - let bt_info = Command::new("bluetoothctl").args(["info", MAC_ADDRESS]).output()?; + let bt_info = Command::new("bluetoothctl").args(["info", mac]).output()?; let bt_str = String::from_utf8_lossy(&bt_info.stdout); if !bt_str.contains("Connected: yes") { diff --git a/src/modules/hardware.rs b/src/modules/hardware.rs index 07d0081..4dc31cd 100644 --- a/src/modules/hardware.rs +++ b/src/modules/hardware.rs @@ -41,7 +41,6 @@ impl HardwareDaemon { let load_avg = System::load_average(); let uptime = System::uptime(); - // Fast O(1) process count by reading loadavg instead of heavy sysinfo process refresh let mut process_count = 0; if let Ok(loadavg_str) = std::fs::read_to_string("/proc/loadavg") { let parts: Vec<&str> = loadavg_str.split_whitespace().collect(); @@ -73,7 +72,6 @@ impl HardwareDaemon { fn poll_gpu(&mut self, gpu: &mut crate::state::GpuState) { gpu.active = false; - // Fast path: if we already detected NVIDIA, don't fallback to AMD/Intel scanning if self.gpu_vendor.as_deref() == Some("NVIDIA") || self.gpu_vendor.is_none() { 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"]) @@ -99,7 +97,6 @@ impl HardwareDaemon { } } - // Fast path: if we detected AMD or Intel, scan sysfs if self.gpu_vendor.as_deref() == Some("AMD") || self.gpu_vendor.as_deref() == Some("Intel") || self.gpu_vendor.is_none() { for i in 0..=3 { let base = format!("/sys/class/drm/card{}/device", i);