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 }