git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

src/stats.rs (22K)

  1 //! System statistics: /proc, /sys, and pactl readers plus the polling task
  2 //! that feeds `SystemStats` updates to the bar.
  3 
  4 use crate::{CustomEvent, SystemStats};
  5 
  6 pub(crate) fn read_cpu_ticks() -> Option<(u64, u64)> {
  7     let stat = std::fs::read_to_string("/proc/stat").ok()?;
  8     let first_line = stat.lines().next()?;
  9     if first_line.starts_with("cpu ") {
 10         let parts: Vec<u64> = first_line
 11             .split_whitespace()
 12             .skip(1)
 13             .filter_map(|s| s.parse::<u64>().ok())
 14             .collect();
 15         if parts.len() >= 4 {
 16             let idle = parts[3];
 17             let total: u64 = parts.iter().sum();
 18             return Some((total, idle));
 19         }
 20     }
 21     None
 22 }
 23 
 24 /// Memory in use as a whole percentage of the total — used being total less
 25 /// free, buffers and page cache, so what an application could not have
 26 /// without the kernel dropping cache first. `None` when /proc/meminfo is
 27 /// unreadable.
 28 pub(crate) fn read_memory_usage() -> Option<u8> {
 29     let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
 30     let mut total = 0.0;
 31     let mut free = 0.0;
 32     let mut buffers = 0.0;
 33     let mut cached = 0.0;
 34     for line in meminfo.lines() {
 35         let kib = |line: &str| line.split_whitespace().nth(1).and_then(|v| v.parse::<f32>().ok());
 36         if line.starts_with("MemTotal:") {
 37             total = kib(line)?;
 38         } else if line.starts_with("MemFree:") {
 39             free = kib(line)?;
 40         } else if line.starts_with("Buffers:") {
 41             buffers = kib(line)?;
 42         } else if line.starts_with("Cached:") {
 43             cached = kib(line)?;
 44         }
 45     }
 46     if total > 0.0 {
 47         let used = total - free - buffers - cached;
 48         Some((used / total * 100.0).round().clamp(0.0, 100.0) as u8)
 49     } else {
 50         None
 51     }
 52 }
 53 
 54 /// The first battery's `(capacity %, charging)`; `None` on a machine
 55 /// without one.
 56 pub(crate) fn read_battery_details() -> Option<(i32, bool)> {
 57     for bat in &["BAT0", "BAT1"] {
 58         let cap_path = format!("/sys/class/power_supply/{}/capacity", bat);
 59         let status_path = format!("/sys/class/power_supply/{}/status", bat);
 60         if let Ok(cap_str) = std::fs::read_to_string(&cap_path) {
 61             let cap = cap_str.trim().parse::<i32>().unwrap_or(0);
 62             let status = std::fs::read_to_string(&status_path).unwrap_or_default();
 63             let is_charging = status.trim() == "Charging";
 64             return Some((cap, is_charging));
 65         }
 66     }
 67     None
 68 }
 69 
 70 /// The first backlight's level as a whole percentage; `None` without one.
 71 pub(crate) fn read_brightness() -> Option<i32> {
 72     let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
 73     for entry in dir.flatten() {
 74         let path = entry.path();
 75         let cur_path = path.join("brightness");
 76         let max_path = path.join("max_brightness");
 77         if cur_path.exists() && max_path.exists() {
 78             let cur_str = std::fs::read_to_string(cur_path).ok()?;
 79             let max_str = std::fs::read_to_string(max_path).ok()?;
 80             let cur = cur_str.trim().parse::<f32>().ok()?;
 81             let max = max_str.trim().parse::<f32>().ok()?;
 82             if max > 0.0 {
 83                 return Some((cur / max * 100.0).round() as i32);
 84             }
 85         }
 86     }
 87     None
 88 }
 89 
 90 /// The top of the link-quality scale in `/proc/net/wireless`. cfg80211
 91 /// synthesizes the wireless-extensions stats for every mac80211 driver as
 92 /// `signal dBm + 110`, clamped to 0..=70, so 70 is the full-strength value
 93 /// on any modern adapter.
 94 const WIFI_QUALITY_MAX: f32 = 70.0;
 95 
 96 /// The Wi-Fi link as `(signal %, connected)`; `None` on a machine without a
 97 /// wireless interface, which hides the readout. With several adapters, a
 98 /// connected one wins over a disconnected one, then the stronger signal.
 99 /// The signal is `None` when the link is up but `/proc/net/wireless` has no
100 /// quality for it.
101 pub(crate) fn read_wifi() -> Option<(Option<u8>, bool)> {
102     let proc = std::fs::read_to_string("/proc/net/wireless").unwrap_or_default();
103     let mut best: Option<(Option<u8>, bool)> = None;
104     for entry in std::fs::read_dir("/sys/class/net").ok()?.flatten() {
105         let path = entry.path();
106         if !path.join("wireless").exists() && !path.join("phy80211").exists() {
107             continue;
108         }
109         let iface = entry.file_name().to_string_lossy().into_owned();
110         let connected = std::fs::read_to_string(path.join("operstate"))
111             .is_ok_and(|s| s.trim() == "up");
112         let signal = connected.then(|| wifi_signal(&proc, &iface)).flatten();
113         let cand = (signal, connected);
114         if best.is_none_or(|b| (cand.1, cand.0) > (b.1, b.0)) {
115             best = Some(cand);
116         }
117     }
118     best
119 }
120 
121 /// `iface`'s link quality from `/proc/net/wireless` as a whole percentage.
122 /// The table's rows are `  wlp0s20f3: 0000   63.  -47.  -256 ...` — the
123 /// interface, a status word, then the quality with a trailing dot.
124 pub(crate) fn wifi_signal(proc_net_wireless: &str, iface: &str) -> Option<u8> {
125     proc_net_wireless.lines().find_map(|line| {
126         let (name, rest) = line.split_once(':')?;
127         if name.trim() != iface {
128             return None;
129         }
130         let qual: f32 = rest.split_whitespace().nth(1)?.trim_end_matches('.').parse().ok()?;
131         Some((qual / WIFI_QUALITY_MAX * 100.0).round().clamp(0.0, 100.0) as u8)
132     })
133 }
134 
135 /// The default sink's `(level %, muted)`; `None` when pactl is unavailable
136 /// or fails. The level is `None` when pactl answered without a percentage.
137 pub(crate) async fn read_volume() -> Option<(Option<u32>, bool)> {
138     let vol_output = match tokio::process::Command::new("pactl")
139         .args(["get-sink-volume", "@DEFAULT_SINK@"])
140         .output()
141         .await
142     {
143         Ok(o) => o,
144         Err(e) => {
145             log::warn!("[read_volume] failed to spawn pactl: {:?}", e);
146             return None;
147         }
148     };
149     if !vol_output.status.success() {
150         log::warn!("[read_volume] pactl get-sink-volume exited with error: {:?}", String::from_utf8_lossy(&vol_output.stderr));
151         return None;
152     }
153     let vol_str = String::from_utf8_lossy(&vol_output.stdout);
154     
155     let mute_output = match tokio::process::Command::new("pactl")
156         .args(["get-sink-mute", "@DEFAULT_SINK@"])
157         .output()
158         .await
159     {
160         Ok(o) => o,
161         Err(e) => {
162             log::warn!("[read_volume] failed to spawn pactl mute: {:?}", e);
163             return None;
164         }
165     };
166     if !mute_output.status.success() {
167         log::warn!("[read_volume] pactl get-sink-mute exited with error: {:?}", String::from_utf8_lossy(&mute_output.stderr));
168         return None;
169     }
170     let mute_str = String::from_utf8_lossy(&mute_output.stdout);
171     let muted = mute_str.contains("yes");
172 
173     let mut pct = None;
174     if let Some(pos) = vol_str.find('%') {
175         let start = vol_str[..pos].rfind(|c: char| !c.is_ascii_digit()).map(|i| i + 1).unwrap_or(0);
176         if let Ok(num) = vol_str[start..pos].parse::<u32>() {
177             pct = Some(num);
178         }
179     }
180 
181     Some((pct, muted))
182 }
183 
184 pub(crate) fn get_initial_stats() -> SystemStats {
185     let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
186     let memory = read_memory_usage();
187 
188     SystemStats {
189         clock,
190         memory,
191         cpu_pct: Some(0),
192         battery: read_battery_details(),
193         volume: pollster::block_on(read_volume()),
194         brightness: read_brightness(),
195         wifi: read_wifi(),
196     }
197 }
198 
199 /// Does `module` paint `field` — one of the [`SystemStats`] field names?
200 /// The same split as `stats_signature`: a single-stat module reads its own
201 /// field, the combined `stats` bar (or an unknown name) reads them all.
202 pub(crate) fn module_reads(module: Option<&str>, field: &str) -> bool {
203     match module {
204         Some("window") | Some("tray") | Some("light_source") => false,
205         Some(m @ ("clock" | "cpu" | "memory" | "battery" | "volume" | "brightness" | "wifi")) => m == field,
206         _ => true,
207     }
208 }
209 
210 /// A CPU reading moves the bar only by at least this many points, or once
211 /// it has been held back for [`CPU_HOLD_S`]. The busy figure jitters by a
212 /// point or two every second at rest, and each change redraws the segment
213 /// (and the compositor re-bakes the blur behind it).
214 const CPU_STEP: u8 = 2;
215 const CPU_HOLD_S: u64 = 5;
216 
217 /// The once-a-second poll. It reads only what `module` paints, and never the
218 /// two values the fast path owns when `fast_levels` is set (the backlight and
219 /// the sink, [`spawn_level_watchers`]): the bar keeps those from the fast
220 /// path, so polling them too only spawned `pactl` twice a second for nothing.
221 /// A clock alone wakes once a minute, on the minute — it shows no seconds.
222 pub(crate) async fn spawn_system_stats(
223     sender: calloop::channel::Sender<CustomEvent>,
224     module: Option<String>,
225     fast_levels: bool,
226 ) {
227     log::info!("[spawn_system_stats] Starting system stats loop!");
228     let module = module.as_deref();
229     let reads = |field: &str| module_reads(module, field);
230     let read_volume_here = reads("volume") && !fast_levels;
231     let read_brightness_here = reads("brightness") && !fast_levels;
232     let clock_only = ["memory", "cpu", "battery", "volume", "brightness", "wifi"]
233         .iter()
234         .all(|f| !reads(f));
235 
236     let mut last_cpu = read_cpu_ticks().unwrap_or((0, 0));
237     let mut shown_cpu: Option<u8> = Some(0);
238     let mut shown_cpu_at = std::time::Instant::now();
239     loop {
240         log::debug!("[spawn_system_stats] loop iteration start");
241         let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
242 
243         let cpu_pct = if reads("cpu") {
244             let sample = read_cpu_ticks().map(|current_cpu| {
245                 let total_diff = current_cpu.0 - last_cpu.0;
246                 let idle_diff = current_cpu.1 - last_cpu.1;
247                 last_cpu = current_cpu;
248                 if total_diff > 0 {
249                     let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
250                     usage.round().clamp(0.0, 100.0) as u8
251                 } else {
252                     0
253                 }
254             });
255             let moved = match (sample, shown_cpu) {
256                 (Some(new), Some(old)) => {
257                     new.abs_diff(old) >= CPU_STEP
258                         || (new != old && shown_cpu_at.elapsed().as_secs() >= CPU_HOLD_S)
259                 }
260                 (a, b) => a != b,
261             };
262             if moved {
263                 shown_cpu = sample;
264                 shown_cpu_at = std::time::Instant::now();
265             }
266             shown_cpu
267         } else {
268             None
269         };
270 
271         let stats = SystemStats {
272             clock,
273             memory: if reads("memory") { read_memory_usage() } else { None },
274             cpu_pct,
275             battery: if reads("battery") { read_battery_details() } else { None },
276             volume: if read_volume_here { read_volume().await } else { None },
277             brightness: if read_brightness_here { read_brightness() } else { None },
278             wifi: if reads("wifi") { read_wifi() } else { None },
279         };
280         log::debug!("[spawn_system_stats] stats: {:?}", stats);
281         let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));
282         let wait = if clock_only {
283             until_next_minute(chrono::Local::now())
284         } else {
285             std::time::Duration::from_secs(1)
286         };
287         tokio::time::sleep(wait).await;
288     }
289 }
290 
291 /// How long until just past the next minute boundary of `now`.
292 pub(crate) fn until_next_minute(now: chrono::DateTime<chrono::Local>) -> std::time::Duration {
293     use chrono::Timelike;
294     let into = std::time::Duration::from_secs(now.second() as u64)
295         + std::time::Duration::from_nanos(now.nanosecond().min(999_999_999) as u64);
296     std::time::Duration::from_secs(60).saturating_sub(into) + std::time::Duration::from_millis(50)
297 }
298 
299 /// How often the backlight is re-read when its change notification cannot be
300 /// had ([`watch_brightness`]'s fallback).
301 const BRIGHTNESS_POLL_MS: u64 = 100;
302 
303 /// How long a sink event is held before the volume is read, swallowing the
304 /// rest of its burst. PulseAudio reports one change as several events (and a
305 /// held volume key as a stream of them); reading once at the end of a burst
306 /// keeps this from falling a process-spawn behind per event.
307 const VOLUME_COALESCE_MS: u64 = 30;
308 
309 /// The fast path for the two values a keypress moves: the backlight and the
310 /// default sink. Both reach the bar the moment they change instead of at the
311 /// next [`spawn_system_stats`] tick, which is a full second at worst — long
312 /// enough that the number visibly lags the key. Where these run, the poll no
313 /// longer reads either value: each watcher is its own safety net (the volume
314 /// one re-reads on every resubscribe, the backlight one falls back to
315 /// polling).
316 ///
317 /// Each change goes to `emit`: the bar turns it into a one-field
318 /// `CustomEvent`, and the launcher daemon into the volume/brightness slider
319 /// (`osd.rs`).
320 pub(crate) async fn spawn_level_watchers<F>(emit: F)
321 where
322     F: Fn(LevelChange) + Clone + Send + Sync + 'static,
323 {
324     tokio::spawn(watch_brightness(emit.clone()));
325     tokio::spawn(watch_volume(emit));
326 }
327 
328 /// One value moved by the fast path ([`spawn_level_watchers`]).
329 #[derive(Debug, Clone, Copy, PartialEq)]
330 pub(crate) enum LevelChange {
331     /// Backlight level %, `None` without a backlight.
332     Brightness(Option<i32>),
333     /// The default sink's `(level %, muted)`, `None` when pactl is unavailable.
334     Volume(Option<(Option<u32>, bool)>),
335 }
336 
337 impl From<LevelChange> for CustomEvent {
338     fn from(change: LevelChange) -> Self {
339         match change {
340             LevelChange::Brightness(b) => CustomEvent::BrightnessUpdated(b),
341             LevelChange::Volume(v) => CustomEvent::VolumeUpdated(v),
342         }
343     }
344 }
345 
346 /// Push every backlight change. The kernel signals `actual_brightness`
347 /// (`sysfs_notify`, from `backlight_generate_event`) on every write to
348 /// `brightness` — `brightnessctl`, the compositor's media keys, firmware
349 /// hotkeys — so a thread blocks in `poll` on it and wakes only then.
350 /// Measured on this panel: the notification lands ~20 ms after a write, and
351 /// nothing fires at rest. Until 2026-10-05 this re-read the files every
352 /// 100 ms, in two processes (the bar's `stats` segment and the launcher's
353 /// slider trigger), forever. Polling remains the fallback when the
354 /// notification cannot be had.
355 async fn watch_brightness(emit: impl Fn(LevelChange) + Send + 'static) {
356     std::thread::Builder::new()
357         .name("backlight-watch".into())
358         .spawn(move || {
359             let mut last = read_brightness();
360             if let Err(e) = brightness_notifications(&emit, &mut last) {
361                 log::info!("[watch_brightness] no change notification ({e}); polling");
362             }
363             loop {
364                 std::thread::sleep(std::time::Duration::from_millis(BRIGHTNESS_POLL_MS));
365                 let cur = read_brightness();
366                 if cur != last {
367                     last = cur;
368                     emit(LevelChange::Brightness(cur));
369                 }
370             }
371         })
372         .ok();
373 }
374 
375 /// Block on the first backlight's `actual_brightness` notification and push
376 /// each moved value. Returns only on an error, for the polling fallback.
377 fn brightness_notifications(
378     emit: &impl Fn(LevelChange),
379     last: &mut Option<i32>,
380 ) -> std::io::Result<()> {
381     use std::io::{Read, Seek};
382     use std::os::fd::AsRawFd;
383 
384     let dir = std::fs::read_dir("/sys/class/backlight")?
385         .flatten()
386         .map(|e| e.path())
387         .find(|p| p.join("brightness").exists() && p.join("max_brightness").exists())
388         .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no backlight"))?;
389     let mut file = std::fs::File::open(dir.join("actual_brightness"))?;
390     let mut buf = [0u8; 32];
391     loop {
392         // sysfs re-arms the notification on a read from the start.
393         file.seek(std::io::SeekFrom::Start(0))?;
394         let _ = file.read(&mut buf)?;
395         let mut pfd = libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLPRI | libc::POLLERR, revents: 0 };
396         let rc = unsafe { libc::poll(&mut pfd, 1, -1) };
397         if rc < 0 {
398             let err = std::io::Error::last_os_error();
399             if err.kind() == std::io::ErrorKind::Interrupted {
400                 continue;
401             }
402             return Err(err);
403         }
404         if pfd.revents & libc::POLLNVAL != 0 {
405             return Err(std::io::Error::other("backlight fd invalid"));
406         }
407         let cur = read_brightness();
408         if cur != *last {
409             *last = cur;
410             emit(LevelChange::Brightness(cur));
411         }
412         // A held key writes in a burst; one read per 10 ms is plenty, and it
413         // bounds the loop should the attribute ever stay readable.
414         std::thread::sleep(std::time::Duration::from_millis(10));
415     }
416 }
417 
418 /// Follow `pactl subscribe` and re-read the sink whenever it reports one.
419 /// The alternative — polling `pactl` fast enough to feel immediate — would
420 /// spawn two processes several times a second; the subscription costs one
421 /// long-lived process and reads only when something actually happened.
422 ///
423 /// A subscription that ends (no pactl, a sound server restart) is retried
424 /// with the same backoff shape the status listener uses, and the one-second
425 /// poll covers the gap in the meantime.
426 async fn watch_volume(emit: impl Fn(LevelChange)) {
427     let mut last = read_volume().await;
428     let mut retry_s = 1u64;
429     loop {
430         match volume_subscription(&emit, &mut last).await {
431             // A subscription that delivered something was working; a fresh
432             // failure after it should start over at the short delay.
433             Ok(true) => retry_s = 1,
434             Ok(false) => {}
435             Err(e) => log::warn!("[watch_volume] pactl subscribe failed: {:?}", e),
436         }
437         tokio::time::sleep(std::time::Duration::from_secs(retry_s)).await;
438         retry_s = (retry_s * 2).min(30);
439     }
440 }
441 
442 /// One run of `pactl subscribe`, ending when the process does. `Ok(true)`
443 /// means it delivered at least one event we acted on.
444 async fn volume_subscription(
445     emit: &impl Fn(LevelChange),
446     last: &mut Option<(Option<u32>, bool)>,
447 ) -> std::io::Result<bool> {
448     use tokio::io::{AsyncBufReadExt, BufReader};
449 
450     let mut cmd = tokio::process::Command::new("pactl");
451     // SAFETY: `prctl` is async-signal-safe and touches only this child's own
452     // process attributes, which is all a pre-exec closure may do.
453     unsafe {
454         cmd.pre_exec(|| {
455             libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
456             Ok(())
457         });
458     }
459     let mut child = cmd
460         .arg("subscribe")
461         // The event lines are matched by their English words, so pin the
462         // locale rather than trusting the session's.
463         .env("LC_ALL", "C")
464         .stdout(std::process::Stdio::piped())
465         .stderr(std::process::Stdio::null())
466         // Both halves of "no orphaned subscriptions": `kill_on_drop` covers
467         // the loop ending under us, and PDEATHSIG covers the bar being
468         // killed outright — the launcher's supervisor restarts module
469         // processes, and a subscription whose reader is gone sits there
470         // until its next write rather than noticing. Observed: an orphan
471         // reparented to init and still sleeping minutes later.
472         .kill_on_drop(true)
473         .spawn()?;
474     let Some(stdout) = child.stdout.take() else {
475         return Ok(false);
476     };
477 
478     // Lines are forwarded through a channel rather than read inline, because
479     // the coalescing wait below cancels its read: `next_line` is not
480     // cancel-safe and would drop a partly-read line, while `recv` is.
481     let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
482     tokio::spawn(async move {
483         let mut lines = BufReader::new(stdout).lines();
484         while let Ok(Some(line)) = lines.next_line().await {
485             if is_sink_event(&line) && tx.send(()).is_err() {
486                 break;
487             }
488         }
489     });
490 
491     // A resubscribe follows a gap in which changes went unseen; nothing else
492     // reads the sink where this runs, so catch up now.
493     let cur = read_volume().await;
494     if cur != *last {
495         *last = cur;
496         emit(LevelChange::Volume(cur));
497     }
498 
499     let mut delivered = false;
500     while rx.recv().await.is_some() {
501         // Swallow the rest of the burst, then read once.
502         let coalesce = std::time::Duration::from_millis(VOLUME_COALESCE_MS);
503         while tokio::time::timeout(coalesce, rx.recv()).await.is_ok() {}
504         delivered = true;
505         let cur = read_volume().await;
506         if cur != *last {
507             *last = cur;
508             emit(LevelChange::Volume(cur));
509         }
510     }
511     let _ = child.wait().await;
512     Ok(delivered)
513 }
514 
515 /// Does a `pactl subscribe` line concern what the volume readout paints?
516 ///
517 /// `sink` is the sink itself; `server` is the default-sink change, which
518 /// moves the readout to a different device's level. `sink-input` is a single
519 /// application's stream and must NOT match — it fires on every player's
520 /// volume, and matching it would re-read the sink constantly during
521 /// playback.
522 pub(crate) fn is_sink_event(line: &str) -> bool {
523     line.contains(" on sink #") || line.contains(" on server")
524 }
525 
526 #[cfg(test)]
527 mod poll_tests {
528     use super::{module_reads, until_next_minute};
529     use chrono::TimeZone;
530 
531     #[test]
532     fn a_single_stat_module_reads_only_its_own_field() {
533         assert!(module_reads(Some("clock"), "clock"));
534         assert!(!module_reads(Some("clock"), "volume"));
535         assert!(!module_reads(Some("clock"), "cpu"));
536         assert!(module_reads(Some("cpu"), "cpu"));
537         assert!(!module_reads(Some("cpu"), "memory"));
538         // The combined bar, and a name this build does not know, read all.
539         assert!(module_reads(Some("stats"), "wifi"));
540         assert!(module_reads(None, "battery"));
541         assert!(!module_reads(Some("tray"), "clock"));
542     }
543 
544     #[test]
545     fn the_clock_wakes_just_past_the_minute() {
546         let at = |s, ms| chrono::Local.with_ymd_and_hms(2026, 10, 5, 21, 30, s).unwrap()
547             + chrono::Duration::milliseconds(ms);
548         assert_eq!(until_next_minute(at(0, 0)).as_millis(), 60_050);
549         assert_eq!(until_next_minute(at(59, 900)).as_millis(), 150);
550         assert_eq!(until_next_minute(at(30, 0)).as_millis(), 30_050);
551     }
552 }