git.lucas.co / cce-status-interface
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 }