added missing docs
Release / Build and Release (push) Successful in 23s

This commit is contained in:
2026-04-05 21:12:11 +02:00
parent f89833a62e
commit ffdb689ef9
18 changed files with 208 additions and 49 deletions
+21 -8
View File
@@ -1,3 +1,10 @@
//! PulseAudio/PipeWire sink + source indicator with live event subscription.
//!
//! The daemon runs on its own OS thread because libpulse's threaded mainloop
//! must drive callbacks inside its own lock scope. Volume/mute changes are
//! routed back via an async [`mpsc`] channel — the module handlers [`run`]s
//! only push commands; the thread performs the actual libpulse calls.
use crate::config::Config;
use crate::error::{FluxoError, Result};
use crate::modules::WaybarModule;
@@ -13,6 +20,7 @@ use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::error;
/// Commands the module handler sends to the audio daemon thread.
pub enum AudioCommand {
ChangeVolume {
is_sink: bool,
@@ -27,13 +35,17 @@ pub enum AudioCommand {
},
}
/// Long-lived daemon driving libpulse's threaded mainloop.
pub struct AudioDaemon;
impl AudioDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self {
Self
}
/// Spawn the audio thread, subscribe to sink/source/server events, and
/// start consuming [`AudioCommand`]s.
pub fn start(
&self,
state_tx: &watch::Sender<AudioState>,
@@ -56,7 +68,6 @@ impl AudioDaemon {
mainloop.lock();
// Wait for context to be ready
loop {
match context.get_state() {
libpulse_binding::context::State::Ready => break,
@@ -74,10 +85,8 @@ impl AudioDaemon {
}
}
// Initial fetch
let _ = fetch_audio_data_sync(&mut context, &state_tx);
// Subscribe to events
let interest =
InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SERVER;
context.subscribe(interest, |_| {});
@@ -196,7 +205,6 @@ impl AudioDaemon {
mainloop.lock();
// Fetch data and update available sinks/sources
let _ = fetch_audio_data_sync(&mut context, &state_tx);
mainloop.unlock();
@@ -207,13 +215,12 @@ impl AudioDaemon {
use std::time::Duration;
/// Trigger async libpulse introspection: server defaults + sink/source lists.
/// Callbacks publish onto `state_tx` as results land.
fn fetch_audio_data_sync(
context: &mut Context,
state_tx: &watch::Sender<AudioState>,
) -> Result<()> {
// We fetch all sinks and sources, and also server info to know defaults.
// The order doesn't strictly matter as long as we update correctly.
let tx_server = state_tx.clone();
context.introspect().get_server_info(move |info| {
let mut current = tx_server.borrow().clone();
@@ -269,6 +276,8 @@ fn device_info_from(
(desc, vol, muted, channels)
}
/// Write `info` into `target` only when `item_name` matches the currently
/// selected default device — other sinks/sources are ignored here.
fn apply_device_info(target: &mut AudioDeviceInfo, item_name: &str, info: (String, u8, bool, u8)) {
if item_name == target.name {
target.description = info.0;
@@ -278,6 +287,7 @@ fn apply_device_info(target: &mut AudioDeviceInfo, item_name: &str, info: (Strin
}
}
/// Dispatch `get_sink_info_list` and collect names into `available_sinks`.
fn fetch_sinks(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
let tx = state_tx.clone();
let pending = PendingList::new();
@@ -313,6 +323,8 @@ fn fetch_sinks(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
});
}
/// Dispatch `get_source_info_list` and collect names (skipping `.monitor`
/// virtual sources) into `available_sources`.
fn fetch_sources(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
let tx = state_tx.clone();
let pending = PendingList::new();
@@ -348,6 +360,8 @@ fn fetch_sources(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
});
}
/// Renders sink/source + dispatches volume/mute/cycle commands.
/// Args: `[sink|source] [show|up|down|mute|cycle] [step]`.
pub struct AudioModule;
impl WaybarModule for AudioModule {
@@ -413,7 +427,6 @@ impl AudioModule {
};
if name.is_empty() {
// Fallback if daemon hasn't populated state yet
return Ok(WaybarOutput {
text: "Audio Loading...".to_string(),
..Default::default()
+10 -5
View File
@@ -1,3 +1,7 @@
//! Screen backlight indicator, driven by `inotify` on
//! `/sys/class/backlight/*/actual_brightness`. Falls back to a 5 s poll loop
//! to catch any missed events.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -11,6 +15,7 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{error, info};
/// Renders the brightness percentage with a vendor-agnostic icon bucket.
pub struct BacklightModule;
impl WaybarModule for BacklightModule {
@@ -47,13 +52,16 @@ impl WaybarModule for BacklightModule {
}
}
/// Background `inotify` watcher thread for the sysfs backlight file.
pub struct BacklightDaemon;
impl BacklightDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self {
Self
}
/// Spawn an OS thread that publishes brightness changes onto `tx`.
pub fn start(&self, tx: watch::Sender<BacklightState>) {
std::thread::spawn(move || {
let base_dir = PathBuf::from("/sys/class/backlight");
@@ -105,12 +113,10 @@ impl BacklightDaemon {
}
};
// Initial poll
let _ = tx.send(BacklightState {
percentage: get_percentage(),
});
// Set up notify watcher
let (ev_tx, ev_rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
@@ -130,9 +136,8 @@ impl BacklightDaemon {
}
loop {
// Block until an event occurs or a timeout to catch missed events
if ev_rx.recv_timeout(Duration::from_secs(5)).is_ok() {
// Debounce rapid events
// Debounce bursts from scroll-driven brightness changes.
std::thread::sleep(Duration::from_millis(50));
while ev_rx.try_recv().is_ok() {}
@@ -140,7 +145,7 @@ impl BacklightDaemon {
percentage: get_percentage(),
});
} else {
// Timeout hit, poll just in case
// Timeout reached — resync in case an event was missed.
let current = get_percentage();
if tx.borrow().percentage != current {
let _ = tx.send(BacklightState {
+12
View File
@@ -1,3 +1,6 @@
//! Per-device BT plugin trait + PixelBuds Pro implementation on top of the
//! Maestro GATT connection.
use crate::config::Config;
use crate::error::{FluxoError, Result as FluxoResult};
use crate::modules::bt::maestro::BudsCommand;
@@ -5,29 +8,38 @@ use crate::state::AppReceivers;
use crate::utils::TokenValue;
use futures::future::BoxFuture;
/// A device-specific adapter that can enrich [`BtState`](crate::state::BtState)
/// with extra metadata and expose control actions (modes).
pub trait BtPlugin: Send + Sync {
/// Plugin identifier used for logging.
fn name(&self) -> &str;
/// Return true if this plugin handles a device with `alias`/`mac`.
fn can_handle(&self, alias: &str, mac: &str) -> bool;
/// Return `(token_name, value)` pairs merged into the rendered template.
fn get_data(
&self,
config: &Config,
state: &AppReceivers,
mac: &str,
) -> BoxFuture<'static, FluxoResult<Vec<(String, TokenValue)>>>;
/// List of mode identifiers the plugin can switch between.
fn get_modes(
&self,
mac: &str,
state: &AppReceivers,
) -> BoxFuture<'static, FluxoResult<Vec<String>>>;
/// Switch device to `mode` (must be one returned by `get_modes`).
fn set_mode(
&self,
mode: &str,
mac: &str,
state: &AppReceivers,
) -> BoxFuture<'static, FluxoResult<()>>;
/// Advance to the next mode in the list (wraps around).
fn cycle_mode(&self, mac: &str, state: &AppReceivers) -> BoxFuture<'static, FluxoResult<()>>;
}
/// Google Pixel Buds Pro plugin. Reads battery + ANC state via Maestro GATT.
pub struct PixelBudsPlugin;
impl BtPlugin for PixelBudsPlugin {
+28 -8
View File
@@ -1,3 +1,11 @@
//! Google Maestro (PixelBuds GATT) integration.
//!
//! Each connected device gets its own [`buds_task`] running on a dedicated
//! single-threaded runtime. The task opens an RFCOMM channel, speaks the
//! Maestro protocol to read battery + ANC state, and listens for settings
//! changes. External callers interact via [`MaestroManager::send_command`]
//! and [`MaestroManager::get_status`].
use crate::state::AppReceivers;
use anyhow::{Context, Result};
use futures::StreamExt;
@@ -7,12 +15,12 @@ use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
// Maestro imports
use maestro::protocol::codec::Codec;
use maestro::pwrpc::client::Client;
use maestro::service::MaestroService;
use maestro::service::settings::{self, SettingValue};
/// Cached per-device snapshot returned to BT plugin consumers.
#[derive(Clone, Default)]
pub struct BudsStatus {
pub left_battery: Option<u8>,
@@ -24,28 +32,35 @@ pub struct BudsStatus {
pub error: Option<String>,
}
/// Command that can be issued against a connected buds device.
pub enum BudsCommand {
/// Set the ANC mode: `active`, `aware`, or `off`.
SetAnc(String),
}
/// Messages sent to the [`MaestroManager`] control thread.
pub enum ManagerCommand {
/// Ensure a [`buds_task`] is running for `mac`; spawn if absent.
EnsureTask(String),
/// Forward a [`BudsCommand`] to the task for `mac`.
SendCommand(String, BudsCommand),
}
/// Owns all buds-task lifetimes and a shared status cache.
pub struct MaestroManager {
statuses: Arc<Mutex<HashMap<String, BudsStatus>>>,
management_tx: mpsc::UnboundedSender<ManagerCommand>,
}
impl MaestroManager {
/// Spawn the management thread + runtime and return a handle.
pub fn new(state: AppReceivers) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<ManagerCommand>();
let statuses = Arc::new(Mutex::new(HashMap::new()));
let statuses_clone = Arc::clone(&statuses);
let state_clone = state.clone();
// Start dedicated BT management thread
// Dedicated thread — bluer uses per-thread local tasks.
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -85,7 +100,7 @@ impl MaestroManager {
}
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {
// Cleanup dropped tasks if needed
// Wake tick: future hook for task-lifecycle cleanup.
}
}
}
@@ -98,17 +113,20 @@ impl MaestroManager {
}
}
/// Return the cached [`BudsStatus`] for `mac` (default if absent).
pub fn get_status(&self, mac: &str) -> BudsStatus {
let statuses = self.statuses.lock().unwrap();
statuses.get(mac).cloned().unwrap_or_default()
}
/// Request that a buds task be running for `mac`. Idempotent.
pub fn ensure_task(&self, mac: &str) {
let _ = self
.management_tx
.send(ManagerCommand::EnsureTask(mac.to_string()));
}
/// Ensure a task exists and forward `cmd` to it.
pub fn send_command(&self, mac: &str, cmd: BudsCommand) -> Result<()> {
self.ensure_task(mac);
let _ = self
@@ -118,6 +136,8 @@ impl MaestroManager {
}
}
/// Per-device async task: opens RFCOMM, runs the Maestro codec, mirrors
/// battery/ANC state into the shared status map, and consumes commands.
async fn buds_task(
mac: &str,
statuses: Arc<Mutex<HashMap<String, BudsStatus>>>,
@@ -150,7 +170,7 @@ async fn buds_task(
break;
}
// Connect to Maestro RFCOMM service
// Maestro historically listens on channel 1 or 2 — probe both.
let mut stream = None;
for channel in [1, 2] {
let socket = match bluer::rfcomm::Socket::new() {
@@ -190,13 +210,11 @@ async fn buds_task(
info!("Connected Maestro RFCOMM to {} on channel", mac);
// Initialize Maestro communication stack
let codec = Codec::new();
let stream = codec.wrap(stream);
let mut client = Client::new(stream);
let handle = client.handle();
// Resolve Maestro channel
let channel = match maestro::protocol::utils::resolve_channel(&mut client).await {
Ok(c) => c,
Err(e) => {
@@ -213,7 +231,7 @@ async fn buds_task(
let mut service = MaestroService::new(handle, channel);
// Update health
// Successful connect — clear health backoff for bt.buds.
{
let mut lock = state.health.write().await;
let health = lock.entry("bt.buds".to_string()).or_default();
@@ -221,7 +239,6 @@ async fn buds_task(
health.backoff_until = None;
}
// Query initial ANC state
if let Ok(val) = service
.read_setting_var(settings::SettingId::CurrentAncrState)
.await
@@ -337,6 +354,7 @@ async fn buds_task(
Ok(())
}
/// String ("active"/"aware"/"off") → Maestro enum; unknown falls back to `Off`.
fn mode_to_anc_state(mode: &str) -> settings::AncState {
match mode {
"active" => settings::AncState::Active,
@@ -346,6 +364,7 @@ fn mode_to_anc_state(mode: &str) -> settings::AncState {
}
}
/// Inverse of [`mode_to_anc_state`] for status readout.
pub fn anc_state_to_string(state: &settings::AncState) -> String {
match state {
settings::AncState::Active => "active".to_string(),
@@ -357,6 +376,7 @@ pub fn anc_state_to_string(state: &settings::AncState) -> String {
static MAESTRO: OnceLock<MaestroManager> = OnceLock::new();
/// Lazily initialise the process-wide [`MaestroManager`] and return a reference.
pub fn get_maestro(state: &AppReceivers) -> &MaestroManager {
MAESTRO.get_or_init(|| MaestroManager::new(state.clone()))
}
+15 -3
View File
@@ -1,3 +1,11 @@
//! Bluetooth indicator + control (BlueZ via `bluer`).
//!
//! Core loop: filter paired+connected audio-sink devices, enrich them via
//! per-device [`BtPlugin`]s (currently PixelBuds via the Maestro GATT
//! protocol), and publish the result as [`BtState`]. The module handler
//! exposes `connect`, `disconnect`, `cycle`, `menu_data`, `get_modes`,
//! `set_mode`, `cycle_mode` actions for the Waybar menu.
pub mod buds;
pub mod maestro;
@@ -14,15 +22,18 @@ use tracing::{error, warn};
use self::buds::{BtPlugin, PixelBudsPlugin};
/// Background poller that syncs connected BlueZ devices into [`BtState`].
pub struct BtDaemon {
session: Option<bluer::Session>,
}
impl BtDaemon {
/// Construct a new daemon. The BlueZ session is lazily created on first poll.
pub fn new() -> Self {
Self { session: None }
}
/// Poll wrapper that logs + swallows errors so the outer loop keeps running.
pub async fn poll(
&mut self,
tx: &watch::Sender<BtState>,
@@ -113,6 +124,8 @@ impl BtDaemon {
static PLUGINS: LazyLock<Vec<Box<dyn BtPlugin>>> =
LazyLock::new(|| vec![Box::new(PixelBudsPlugin)]);
/// After a user-initiated connect/disconnect, schedule a staircase of
/// forced polls so the UI catches up even if BlueZ is slow to settle.
fn trigger_robust_poll(state: AppReceivers) {
tokio::spawn(async move {
for delay in [200, 500, 1000, 2000, 3000] {
@@ -142,6 +155,7 @@ fn find_device<'a>(bt_state: &'a BtState, mac: &str) -> Option<&'a BtDeviceInfo>
bt_state.devices.iter().find(|d| d.device_address == mac)
}
/// Renders the current BT status + handles control actions.
pub struct BtModule;
impl WaybarModule for BtModule {
@@ -194,7 +208,6 @@ impl WaybarModule for BtModule {
"menu_data" => {
let mut lines = Vec::new();
// Connected devices
for dev in &bt_state.devices {
lines.push(format!(
"CONNECTED:{}|{}",
@@ -202,7 +215,7 @@ impl WaybarModule for BtModule {
));
}
// Paired-but-not-connected devices
// Also surface paired-but-not-connected devices for the menu.
if let Ok(session) = bluer::Session::new().await
&& let Ok(adapter) = session.default_adapter().await
&& let Ok(addresses) = adapter.device_addresses().await
@@ -286,7 +299,6 @@ impl WaybarModule for BtModule {
_ => {}
}
// "show" and fallthrough
if !bt_state.adapter_powered {
return Ok(WaybarOutput {
text: config.bt.format_disabled.clone(),
+5
View File
@@ -1,3 +1,6 @@
//! Btrfs pool renderer: sums usage across all btrfs-typed mounts seen in the
//! `disks` watch channel. Dispatch-only (no dedicated poll task).
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -5,6 +8,8 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template};
/// Aggregates used/total across every mount whose filesystem name contains
/// `btrfs`. Emits `No BTRFS` when none are present.
pub struct BtrfsModule;
impl WaybarModule for BtrfsModule {
+3
View File
@@ -1,3 +1,5 @@
//! CPU usage + temperature renderer. Reads from the `cpu` watch channel.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders CPU usage/temp using [`CpuConfig::format`](crate::config::CpuConfig).
pub struct CpuModule;
impl WaybarModule for CpuModule {
+4
View File
@@ -1,3 +1,5 @@
//! Filesystem usage renderer. Args: `[mountpoint]` (default `/`).
use crate::config::Config;
use crate::error::{FluxoError, Result};
use crate::modules::WaybarModule;
@@ -5,6 +7,8 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders used/total for a given mount point. Returns [`FluxoError::Module`]
/// if the mount point isn't present in the current disk snapshot.
pub struct DiskModule;
impl WaybarModule for DiskModule {
+13 -4
View File
@@ -1,3 +1,9 @@
//! Do-Not-Disturb toggle + status for SwayNC or Dunst.
//!
//! SwayNC exposes a `dnd` property on its `org.erikreider.swaync.control`
//! interface that fires PropertiesChanged signals, so we subscribe. Dunst has
//! no change signal for its `paused` property, so we fall back to a 2 s poll.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -11,6 +17,7 @@ use zbus::proxy;
use zbus::zvariant::OwnedValue;
use zbus::{Connection, fdo::PropertiesProxy};
/// Renders + toggles DND state. Args: `["show"]` (default) or `["toggle"]`.
pub struct DndModule;
/// Read dunst's `paused` property via raw D-Bus call.
@@ -61,7 +68,6 @@ impl WaybarModule for DndModule {
message: format!("DBus connection failed: {}", e),
})?;
// Try SwayNC
if let Ok(proxy) = SwayncControlProxy::new(&connection).await
&& let Ok(is_dnd) = proxy.dnd().await
{
@@ -69,7 +75,6 @@ impl WaybarModule for DndModule {
return Ok(WaybarOutput::default());
}
// Try Dunst via raw D-Bus
if let Ok(is_paused) = dunst_get_paused(&connection).await {
let _ = dunst_set_paused(&connection, !is_paused).await;
return Ok(WaybarOutput::default());
@@ -101,6 +106,8 @@ impl WaybarModule for DndModule {
}
}
/// Background watcher that keeps [`DndState`] in sync with the active
/// notification daemon (SwayNC via signals, Dunst via polling).
pub struct DndDaemon;
#[proxy(
@@ -116,10 +123,12 @@ trait SwayncControl {
}
impl DndDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self {
Self
}
/// Spawn a supervised listen loop that reconnects with a 5 s backoff.
pub fn start(&self, tx: watch::Sender<DndState>) {
tokio::spawn(async move {
loop {
@@ -136,7 +145,6 @@ impl DndDaemon {
info!("Connected to D-Bus for DND monitoring");
// Try SwayNC first (signal-based)
if let Ok(proxy) = SwayncControlProxy::new(&connection).await
&& let Ok(is_dnd) = proxy.dnd().await
{
@@ -164,7 +172,8 @@ impl DndDaemon {
return Err(anyhow::anyhow!("SwayNC DND stream ended"));
}
// Try Dunst via raw D-Bus calls (bypasses zbus proxy issues)
// Dunst: raw D-Bus call avoids zbus proxy typing quirks with its
// non-standard `org.dunstproject.cmd0` interface.
match dunst_get_paused(&connection).await {
Ok(is_paused) => {
info!("Found Dunst, using polling-based DND monitoring");
+5
View File
@@ -1,3 +1,6 @@
//! Gamemode indicator. Queries Hyprland's animation setting over its IPC
//! socket; animations disabled => gamemode active. Dispatch-only.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -6,6 +9,7 @@ use crate::state::AppReceivers;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
/// Renders a glyph depending on whether Hyprland animations are disabled.
pub struct GameModule;
impl WaybarModule for GameModule {
@@ -38,6 +42,7 @@ impl WaybarModule for GameModule {
}
}
/// Send `cmd` to Hyprland's `.socket.sock` and return the response body.
async fn hyprland_ipc(cmd: &str) -> Result<String> {
let path = crate::utils::get_hyprland_socket(".socket.sock")?;
+4
View File
@@ -1,3 +1,6 @@
//! GPU renderer. Picks a vendor-specific format string (AMD/Intel/NVIDIA) and
//! reads from the `gpu` watch channel.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -5,6 +8,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders GPU usage / VRAM / temp using the per-vendor format from config.
pub struct GpuModule;
impl WaybarModule for GpuModule {
+22 -5
View File
@@ -1,7 +1,16 @@
//! Unified CPU/memory/sys/GPU/disk poller.
//!
//! CPU/memory/sys are sampled every fast tick (1 s). GPU polls every 5th fast
//! tick via [`poll_slow`], and disks every 10th (they rarely change). GPU
//! vendor is detected once by probing nvidia-smi / `/sys/class/drm/*`, then
//! cached so subsequent polls take the fast path.
use crate::state::{CpuState, DiskInfo, GpuState, MemoryState, SysState};
use sysinfo::{Components, Disks, System};
use tokio::sync::watch;
/// Long-lived hardware sampler. Holds the `sysinfo::System` handle so
/// successive refreshes can diff against prior samples.
pub struct HardwareDaemon {
sys: System,
components: Components,
@@ -11,6 +20,7 @@ pub struct HardwareDaemon {
}
impl HardwareDaemon {
/// Build a new daemon with an initial `sysinfo` snapshot.
pub fn new() -> Self {
let mut sys = System::new();
sys.refresh_cpu_usage();
@@ -21,10 +31,13 @@ impl HardwareDaemon {
components,
gpu_vendor: None,
gpu_poll_counter: 0,
disk_poll_counter: 9, // Start at 9 to poll on the first tick
// Start at 9 so (counter + 1) % 10 == 0 on the first tick.
disk_poll_counter: 9,
}
}
/// Fast path: refresh CPU usage, memory, temperatures, load avg, uptime.
/// Called every daemon tick.
pub async fn poll_fast(
&mut self,
cpu_tx: &watch::Sender<CpuState>,
@@ -96,12 +109,13 @@ impl HardwareDaemon {
let _ = sys_tx.send(sys);
}
/// Slow path: GPU every 5 ticks, disks every 10 ticks. Each sub-poll
/// runs off the hot loop before any state is published.
pub async fn poll_slow(
&mut self,
gpu_tx: &watch::Sender<GpuState>,
disks_tx: &watch::Sender<Vec<DiskInfo>>,
) {
// 1. Gather GPU data outside of lock
let mut gpu_state = crate::state::GpuState::default();
self.gpu_poll_counter = (self.gpu_poll_counter + 1) % 5;
let should_poll_gpu = self.gpu_poll_counter == 0;
@@ -109,7 +123,6 @@ impl HardwareDaemon {
self.poll_gpu(&mut gpu_state).await;
}
// 2. Gather Disk data outside of lock
let mut disks_data = None;
self.disk_poll_counter = (self.disk_poll_counter + 1) % 10;
if self.disk_poll_counter == 0 {
@@ -130,7 +143,6 @@ impl HardwareDaemon {
);
}
// 3. Apply to state
if should_poll_gpu {
let _ = gpu_tx.send(gpu_state);
}
@@ -140,6 +152,7 @@ impl HardwareDaemon {
}
}
/// Dispatch to the cached vendor's probe, or run detection on first call.
async fn poll_gpu(&mut self, gpu: &mut crate::state::GpuState) {
gpu.active = false;
@@ -154,7 +167,7 @@ impl HardwareDaemon {
Self::poll_intel(gpu);
}
_ => {
// Detection pass: try each vendor, cache the first that responds.
// First run — probe each vendor and cache the first hit.
Self::poll_nvidia(gpu).await;
if gpu.active {
self.gpu_vendor = Some("NVIDIA".to_string());
@@ -173,6 +186,7 @@ impl HardwareDaemon {
}
}
/// Shell out to `nvidia-smi --query-gpu=...` for utilization/VRAM/temp.
async fn poll_nvidia(gpu: &mut crate::state::GpuState) {
let Ok(output) = tokio::process::Command::new("nvidia-smi")
.args([
@@ -202,6 +216,7 @@ impl HardwareDaemon {
}
}
/// Read amdgpu sysfs entries under `/sys/class/drm/card*/device`.
fn poll_amd(gpu: &mut crate::state::GpuState) {
for i in 0..=3 {
let base = format!("/sys/class/drm/card{}/device", i);
@@ -238,6 +253,8 @@ impl HardwareDaemon {
}
}
/// Read i915/xe sysfs `gt_cur_freq_mhz`; approximate "usage" as
/// current/max frequency since Intel has no direct utilization counter.
fn poll_intel(gpu: &mut crate::state::GpuState) {
for i in 0..=3 {
let base = format!("/sys/class/drm/card{}/device", i);
+12 -4
View File
@@ -1,3 +1,7 @@
//! Keyboard layout indicator backed by Hyprland's event socket
//! (`.socket2.sock`). Also seeds the initial layout by shelling out to
//! `hyprctl devices -j` once at startup.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -10,6 +14,7 @@ use tokio::net::UnixStream;
use tokio::sync::watch;
use tracing::{error, info};
/// Renders the current keyboard layout from [`KeyboardState`].
pub struct KeyboardModule;
impl WaybarModule for KeyboardModule {
@@ -44,19 +49,22 @@ impl WaybarModule for KeyboardModule {
}
}
/// Background watcher that subscribes to `activelayout>>` events emitted by
/// Hyprland's event socket.
pub struct KeyboardDaemon;
impl KeyboardDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self {
Self
}
/// Spawn a supervised listen loop that reconnects with a 5 s backoff.
pub fn start(&self, tx: watch::Sender<KeyboardState>) {
tokio::spawn(async move {
loop {
if let Err(e) = Self::listen_loop(&tx).await {
error!("Keyboard layout listener error: {}", e);
// Fallback to waiting before reconnecting to prevent tight loop
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
@@ -71,7 +79,6 @@ impl KeyboardDaemon {
let reader = BufReader::new(stream);
let mut lines = reader.lines();
// Fetch initial layout natively via hyprctl
if let Ok(output) = tokio::process::Command::new("hyprctl")
.args(["devices", "-j"])
.output()
@@ -80,7 +87,8 @@ impl KeyboardDaemon {
&& let Some(keyboards) = json.get("keyboards").and_then(|v| v.as_array())
&& let Some(main_kb) = keyboards.last()
{
// The last active one is usually the main one
// `keyboards.last()` is the most recently registered device,
// which is typically the main one for single-keyboard setups.
if let Some(layout) = main_kb.get("active_keymap").and_then(|v| v.as_str()) {
let _ = tx.send(KeyboardState {
layout: layout.to_string(),
@@ -89,8 +97,8 @@ impl KeyboardDaemon {
}
while let Ok(Some(line)) = lines.next_line().await {
// Event payload: `keyboard_name,layout_name`.
if let Some(payload) = line.strip_prefix("activelayout>>") {
// payload format: keyboard_name,layout_name
let parts: Vec<&str> = payload.splitn(2, ',').collect();
if parts.len() == 2 {
let layout = parts[1].to_string();
+3
View File
@@ -1,3 +1,5 @@
//! RAM usage renderer. Reads from the `memory` watch channel.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders used/total GB with usage classification for Waybar CSS.
pub struct MemoryModule;
impl WaybarModule for MemoryModule {
+26 -5
View File
@@ -1,3 +1,11 @@
//! MPRIS media player indicator.
//!
//! Subscribes to `PlaybackStatus` and `Metadata` property-changed streams on
//! the first `org.mpris.MediaPlayer2.*` name that appears on the session bus,
//! so the indicator is truly signal-driven (no 2 s polling). A 10 s heartbeat
//! verifies the player is still there. Optional marquee scrolling is driven
//! by [`mpris_scroll_ticker`] from [`crate::daemon`].
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -10,6 +18,7 @@ use tokio::time::Duration;
use tracing::{debug, info};
use zbus::{Connection, proxy};
/// Render the user's format string + derive the Waybar CSS class from state.
fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str) {
let status_icon = if mpris.is_playing {
"󰏤"
@@ -40,6 +49,7 @@ fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str)
(text, class)
}
/// Return a cyclic `max_len`-wide window over `full_text + separator`.
fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator: &str) -> String {
let char_count = full_text.chars().count();
let total_len = char_count + separator.chars().count();
@@ -53,6 +63,7 @@ fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator
.collect()
}
/// Truncate `text` to `max_len` chars, appending `...` when cut.
fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
let char_count = text.chars().count();
if char_count <= max_len {
@@ -62,6 +73,7 @@ fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
format!("{}...", truncated)
}
/// Renders the current player state, applying scroll/truncate per config.
pub struct MprisModule;
impl WaybarModule for MprisModule {
@@ -111,6 +123,9 @@ impl WaybarModule for MprisModule {
}
}
/// Drive the marquee animation: advance the offset every `scroll_speed` ms
/// while a track is playing, and emit a fresh generation on `tick_tx` so the
/// mpris signaler arm fires. Resets offset when the track changes.
pub async fn mpris_scroll_ticker(
config: Arc<RwLock<Config>>,
mut mpris_rx: watch::Receiver<MprisState>,
@@ -152,13 +167,15 @@ pub async fn mpris_scroll_ticker(
continue;
}
// Not scrolling — wait for next state change
// Not scrolling — sleep until the next player state change.
if mpris_rx.changed().await.is_err() {
break;
}
}
}
/// Background watcher that discovers the active MPRIS player and mirrors
/// its `PlaybackStatus` + `Metadata` properties into [`MprisState`].
pub struct MprisDaemon;
#[proxy(
@@ -185,10 +202,12 @@ trait MprisPlayer {
}
impl MprisDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self {
Self
}
/// Spawn a supervised listen loop with a 5 s reconnect backoff.
pub fn start(&self, tx: watch::Sender<MprisState>) {
tokio::spawn(async move {
loop {
@@ -209,7 +228,6 @@ impl MprisDaemon {
let dbus_proxy = DBusProxy::new(&connection).await?;
loop {
// Discovery pass: find an active MPRIS player.
let names = dbus_proxy.list_names().await?;
let active_player = names
.into_iter()
@@ -217,7 +235,6 @@ impl MprisDaemon {
let Some(player_name) = active_player else {
send_stopped_if_changed(tx);
// No player — wait and re-discover.
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
};
@@ -234,7 +251,6 @@ impl MprisDaemon {
}
};
// Initial fetch and then signal-driven updates via PropertiesChanged.
update_from_player(&player_proxy, tx).await;
let mut status_stream = player_proxy.receive_playback_status_changed().await;
@@ -249,7 +265,7 @@ impl MprisDaemon {
update_from_player(&player_proxy, tx).await;
}
_ = tokio::time::sleep(Duration::from_secs(10)) => {
// Heartbeat: verify the player is still on the bus.
// Heartbeat: re-check that the player name is still owned.
let current = dbus_proxy.list_names().await.unwrap_or_default();
if !current.iter().any(|n| n == &player_name) {
break;
@@ -261,6 +277,8 @@ impl MprisDaemon {
}
}
/// Fetch `PlaybackStatus` + `Metadata` and publish only when they differ
/// from the previous [`MprisState`] (to avoid spurious watch wake-ups).
async fn update_from_player(player: &MprisPlayerProxy<'_>, tx: &watch::Sender<MprisState>) {
let status = player.playback_status().await.unwrap_or_default();
let metadata = player.metadata().await.unwrap_or_default();
@@ -291,6 +309,7 @@ async fn update_from_player(player: &MprisPlayerProxy<'_>, tx: &watch::Sender<Mp
}
}
/// Extract `xesam:artist` (string or array), `xesam:title`, `xesam:album`.
fn parse_metadata(
metadata: &std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
) -> (String, String, String) {
@@ -325,6 +344,8 @@ fn parse_metadata(
(artist, title, album)
}
/// Publish a cleared/stopped [`MprisState`] if the current state isn't already
/// that. Called when no player is on the bus.
fn send_stopped_if_changed(tx: &watch::Sender<MprisState>) {
let current = tx.borrow();
if !current.is_stopped || !current.title.is_empty() {
+18 -5
View File
@@ -1,3 +1,10 @@
//! Primary-interface throughput renderer + polling daemon.
//!
//! The daemon picks the interface with the longest-prefix default route (see
//! [`get_primary_interface`]) and computes rx/tx rates as byte-count deltas
//! between successive polls. Well-known VPN interface prefixes get a lock
//! glyph prepended to the rendered text.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -9,8 +16,10 @@ use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
/// Renders interface / IP / rx / tx for the detected primary route.
pub struct NetworkModule;
/// Background poller that tracks byte counters across ticks to derive rates.
pub struct NetworkDaemon {
last_time: u64,
last_rx_bytes: u64,
@@ -22,6 +31,7 @@ pub struct NetworkDaemon {
type PollResult = crate::error::Result<(String, Option<String>, Option<(u64, u64)>)>;
impl NetworkDaemon {
/// Build a fresh daemon with no prior byte-count samples.
pub fn new() -> Self {
Self {
last_time: 0,
@@ -32,6 +42,9 @@ impl NetworkDaemon {
}
}
/// Detect the primary interface, read `/sys/class/net/*/statistics`, and
/// publish a new [`NetworkState`] onto `state_tx`. Interface/byte reads
/// run via [`tokio::task::spawn_blocking`] so the runtime isn't starved.
pub async fn poll(
&mut self,
state_tx: &watch::Sender<NetworkState>,
@@ -56,7 +69,6 @@ impl NetworkDaemon {
} else {
self.cached_interface = None;
self.cached_ip = None;
// Provide a default state for "No connection"
let mut network = state_tx.borrow().clone();
network.interface.clear();
network.ip.clear();
@@ -71,7 +83,6 @@ impl NetworkDaemon {
let interface = if let Some(ref interface) = self.cached_interface {
interface.clone()
} else {
// No interface detected
let mut network = state_tx.borrow().clone();
network.interface.clear();
network.ip.clear();
@@ -107,7 +118,7 @@ impl NetworkDaemon {
network.ip = self.cached_ip.clone().unwrap_or_default();
let _ = state_tx.send(network);
} else {
// First poll: no speed data yet, but update interface/ip
// First poll has no prior sample — publish iface/ip only.
let mut network = state_tx.borrow().clone();
network.interface = interface.clone();
network.ip = self.cached_ip.clone().unwrap_or_default();
@@ -118,7 +129,6 @@ impl NetworkDaemon {
self.last_rx_bytes = rx_bytes_now;
self.last_tx_bytes = tx_bytes_now;
} else {
// Read failed, might be down
self.cached_interface = None;
return Err(crate::error::FluxoError::Network(format!(
"Failed to read bytes for {}",
@@ -182,6 +192,8 @@ impl WaybarModule for NetworkModule {
}
}
/// Parse `/proc/net/route` to find the default-route interface. When several
/// defaults exist, prefer the one with the longest netmask, then lowest metric.
fn get_primary_interface() -> Result<String> {
let content = std::fs::read_to_string("/proc/net/route")?;
@@ -200,7 +212,6 @@ fn get_primary_interface() -> Result<String> {
}
}
// Sort by mask descending (longest prefix match first), then by metric ascending
defaults.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
if let Some((_, _, dev)) = defaults.first() {
Ok(dev.clone())
@@ -209,6 +220,7 @@ fn get_primary_interface() -> Result<String> {
}
}
/// First IPv4 address for `interface`, via `getifaddrs`. `None` if absent.
fn get_ip_address(interface: &str) -> Option<String> {
let addrs = getifaddrs().ok()?;
for ifaddr in addrs {
@@ -222,6 +234,7 @@ fn get_ip_address(interface: &str) -> Option<String> {
None
}
/// Read `(rx_bytes, tx_bytes)` counters from sysfs for `interface`.
fn get_bytes(interface: &str) -> Result<(u64, u64)> {
let rx_path = format!("/sys/class/net/{}/statistics/rx_bytes", interface);
let tx_path = format!("/sys/class/net/{}/statistics/tx_bytes", interface);
+4 -2
View File
@@ -1,3 +1,6 @@
//! Battery/AC indicator via `/sys/class/power_supply`. Dispatch-only - reads
//! sysfs on demand rather than polling into a watch channel.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -6,6 +9,7 @@ use crate::state::AppReceivers;
use crate::utils::{TokenValue, format_template};
use std::fs;
/// Renders battery percentage + charge state (critical/warning/bat/charging/ac).
pub struct PowerModule;
impl WaybarModule for PowerModule {
@@ -18,7 +22,6 @@ impl WaybarModule for PowerModule {
let critical_threshold = 15;
let warning_threshold = 50;
// Find the first battery
let mut battery_path = None;
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
for entry in entries.flatten() {
@@ -30,7 +33,6 @@ impl WaybarModule for PowerModule {
}
}
// Check AC status
let mut ac_online = false;
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
for entry in entries.flatten() {
+3
View File
@@ -1,3 +1,5 @@
//! Uptime + load average renderer. Reads from the `sys` watch channel.
use crate::config::Config;
use crate::error::Result;
use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers;
use crate::utils::{TokenValue, format_template};
/// Renders uptime and load averages with a detailed tooltip.
pub struct SysModule;
impl WaybarModule for SysModule {