status bar
git clone https://git.lucas.co/cce-status-interface.git
src/stats.rs (12.6K)
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 default sink's `(level %, muted)`; `None` when pactl is unavailable
91 /// or fails. The level is `None` when pactl answered without a percentage.
92 pub(crate) async fn read_volume() -> Option<(Option<u32>, bool)> {
93 let vol_output = match tokio::process::Command::new("pactl")
94 .args(["get-sink-volume", "@DEFAULT_SINK@"])
95 .output()
96 .await
97 {
98 Ok(o) => o,
99 Err(e) => {
100 log::warn!("[read_volume] failed to spawn pactl: {:?}", e);
101 return None;
102 }
103 };
104 if !vol_output.status.success() {
105 log::warn!("[read_volume] pactl get-sink-volume exited with error: {:?}", String::from_utf8_lossy(&vol_output.stderr));
106 return None;
107 }
108 let vol_str = String::from_utf8_lossy(&vol_output.stdout);
109
110 let mute_output = match tokio::process::Command::new("pactl")
111 .args(["get-sink-mute", "@DEFAULT_SINK@"])
112 .output()
113 .await
114 {
115 Ok(o) => o,
116 Err(e) => {
117 log::warn!("[read_volume] failed to spawn pactl mute: {:?}", e);
118 return None;
119 }
120 };
121 if !mute_output.status.success() {
122 log::warn!("[read_volume] pactl get-sink-mute exited with error: {:?}", String::from_utf8_lossy(&mute_output.stderr));
123 return None;
124 }
125 let mute_str = String::from_utf8_lossy(&mute_output.stdout);
126 let muted = mute_str.contains("yes");
127
128 let mut pct = None;
129 if let Some(pos) = vol_str.find('%') {
130 let start = vol_str[..pos].rfind(|c: char| !c.is_ascii_digit()).map(|i| i + 1).unwrap_or(0);
131 if let Ok(num) = vol_str[start..pos].parse::<u32>() {
132 pct = Some(num);
133 }
134 }
135
136 Some((pct, muted))
137 }
138
139 pub(crate) fn get_initial_stats() -> SystemStats {
140 let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
141 let memory = read_memory_usage();
142
143 SystemStats {
144 clock,
145 memory,
146 cpu_pct: Some(0),
147 battery: read_battery_details(),
148 volume: pollster::block_on(read_volume()),
149 brightness: read_brightness(),
150 }
151 }
152
153 pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEvent>) {
154 log::info!("[spawn_system_stats] Starting system stats loop!");
155 let mut last_cpu = read_cpu_ticks().unwrap_or((0, 0));
156 loop {
157 log::debug!("[spawn_system_stats] loop iteration start");
158 let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
159 let memory = read_memory_usage();
160
161 let cpu_pct = if let Some(current_cpu) = read_cpu_ticks() {
162 let total_diff = current_cpu.0 - last_cpu.0;
163 let idle_diff = current_cpu.1 - last_cpu.1;
164 last_cpu = current_cpu;
165 if total_diff > 0 {
166 let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
167 Some(usage.round().clamp(0.0, 100.0) as u8)
168 } else {
169 Some(0)
170 }
171 } else {
172 None
173 };
174
175 let stats = SystemStats {
176 clock,
177 memory,
178 cpu_pct,
179 battery: read_battery_details(),
180 volume: read_volume().await,
181 brightness: read_brightness(),
182 };
183 log::debug!("[spawn_system_stats] stats: {:?}", stats);
184 let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));
185 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
186 }
187 }
188
189 /// How often the backlight is re-read on the fast path. Two small sysfs
190 /// reads, so the cost is a rounding error next to the once-a-second poll's
191 /// two `pactl` processes — and nothing is sent unless the value moved, so an
192 /// unchanged backlight never wakes the bar's event loop.
193 const BRIGHTNESS_POLL_MS: u64 = 100;
194
195 /// How long a sink event is held before the volume is read, swallowing the
196 /// rest of its burst. PulseAudio reports one change as several events (and a
197 /// held volume key as a stream of them); reading once at the end of a burst
198 /// keeps this from falling a process-spawn behind per event.
199 const VOLUME_COALESCE_MS: u64 = 30;
200
201 /// The fast path for the two values a keypress moves: the backlight and the
202 /// default sink. Both reach the bar the moment they change instead of at the
203 /// next [`spawn_system_stats`] tick, which is a full second at worst — long
204 /// enough that the number visibly lags the key. The one-second poll still
205 /// reads both, so it remains the safety net if either watcher cannot run.
206 pub(crate) async fn spawn_level_watchers(sender: calloop::channel::Sender<CustomEvent>) {
207 tokio::spawn(watch_brightness(sender.clone()));
208 tokio::spawn(watch_volume(sender));
209 }
210
211 /// Poll `/sys/class/backlight` and push every change. `brightnessctl` writes
212 /// the sysfs attribute directly (see the compositor's media-key bindings), so
213 /// there is nothing to subscribe to — but the read is two small files, and
214 /// only a moved value is sent.
215 async fn watch_brightness(sender: calloop::channel::Sender<CustomEvent>) {
216 let mut last = read_brightness();
217 loop {
218 tokio::time::sleep(std::time::Duration::from_millis(BRIGHTNESS_POLL_MS)).await;
219 let cur = read_brightness();
220 if cur != last {
221 last = cur;
222 let _ = sender.send(CustomEvent::BrightnessUpdated(cur));
223 }
224 }
225 }
226
227 /// Follow `pactl subscribe` and re-read the sink whenever it reports one.
228 /// The alternative — polling `pactl` fast enough to feel immediate — would
229 /// spawn two processes several times a second; the subscription costs one
230 /// long-lived process and reads only when something actually happened.
231 ///
232 /// A subscription that ends (no pactl, a sound server restart) is retried
233 /// with the same backoff shape the status listener uses, and the one-second
234 /// poll covers the gap in the meantime.
235 async fn watch_volume(sender: calloop::channel::Sender<CustomEvent>) {
236 let mut last = read_volume().await;
237 let mut retry_s = 1u64;
238 loop {
239 match volume_subscription(&sender, &mut last).await {
240 // A subscription that delivered something was working; a fresh
241 // failure after it should start over at the short delay.
242 Ok(true) => retry_s = 1,
243 Ok(false) => {}
244 Err(e) => log::warn!("[watch_volume] pactl subscribe failed: {:?}", e),
245 }
246 tokio::time::sleep(std::time::Duration::from_secs(retry_s)).await;
247 retry_s = (retry_s * 2).min(30);
248 }
249 }
250
251 /// One run of `pactl subscribe`, ending when the process does. `Ok(true)`
252 /// means it delivered at least one event we acted on.
253 async fn volume_subscription(
254 sender: &calloop::channel::Sender<CustomEvent>,
255 last: &mut Option<(Option<u32>, bool)>,
256 ) -> std::io::Result<bool> {
257 use tokio::io::{AsyncBufReadExt, BufReader};
258
259 let mut cmd = tokio::process::Command::new("pactl");
260 // SAFETY: `prctl` is async-signal-safe and touches only this child's own
261 // process attributes, which is all a pre-exec closure may do.
262 unsafe {
263 cmd.pre_exec(|| {
264 libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
265 Ok(())
266 });
267 }
268 let mut child = cmd
269 .arg("subscribe")
270 // The event lines are matched by their English words, so pin the
271 // locale rather than trusting the session's.
272 .env("LC_ALL", "C")
273 .stdout(std::process::Stdio::piped())
274 .stderr(std::process::Stdio::null())
275 // Both halves of "no orphaned subscriptions": `kill_on_drop` covers
276 // the loop ending under us, and PDEATHSIG covers the bar being
277 // killed outright — the launcher's supervisor restarts module
278 // processes, and a subscription whose reader is gone sits there
279 // until its next write rather than noticing. Observed: an orphan
280 // reparented to init and still sleeping minutes later.
281 .kill_on_drop(true)
282 .spawn()?;
283 let Some(stdout) = child.stdout.take() else {
284 return Ok(false);
285 };
286
287 // Lines are forwarded through a channel rather than read inline, because
288 // the coalescing wait below cancels its read: `next_line` is not
289 // cancel-safe and would drop a partly-read line, while `recv` is.
290 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
291 tokio::spawn(async move {
292 let mut lines = BufReader::new(stdout).lines();
293 while let Ok(Some(line)) = lines.next_line().await {
294 if is_sink_event(&line) && tx.send(()).is_err() {
295 break;
296 }
297 }
298 });
299
300 let mut delivered = false;
301 while rx.recv().await.is_some() {
302 // Swallow the rest of the burst, then read once.
303 let coalesce = std::time::Duration::from_millis(VOLUME_COALESCE_MS);
304 while tokio::time::timeout(coalesce, rx.recv()).await.is_ok() {}
305 delivered = true;
306 let cur = read_volume().await;
307 if cur != *last {
308 *last = cur;
309 let _ = sender.send(CustomEvent::VolumeUpdated(cur));
310 }
311 }
312 let _ = child.wait().await;
313 Ok(delivered)
314 }
315
316 /// Does a `pactl subscribe` line concern what the volume readout paints?
317 ///
318 /// `sink` is the sink itself; `server` is the default-sink change, which
319 /// moves the readout to a different device's level. `sink-input` is a single
320 /// application's stream and must NOT match — it fires on every player's
321 /// volume, and matching it would re-read the sink constantly during
322 /// playback.
323 pub(crate) fn is_sink_event(line: &str) -> bool {
324 line.contains(" on sink #") || line.contains(" on server")
325 }