Skip to main content

i3status_rs/blocks/
cpu.rs

1//! CPU statistics
2//!
3//! # Configuration
4//!
5//! Key | Values | Default
6//! ----|--------|--------
7//! `format` | A [MultiFormat][MaybeMultiFormatConfig] string to customise the output of this block. See below for available placeholders. | `[" $icon $utilization "]`
8//! `interval` | Update interval in seconds | `5`
9//! `info_cpu` | Percentage of CPU usage, where state is set to info | `30.0`
10//! `warning_cpu` | Percentage of CPU usage, where state is set to warning | `60.0`
11//! `critical_cpu` | Percentage of CPU usage, where state is set to critical | `90.0`
12//!
13//! Placeholder      | Value                                                                | Type   | Unit
14//! -----------------|----------------------------------------------------------------------|--------|---------------
15//! `icon`           | An icon                                                              | Icon   | -
16//! `utilization`    | Average CPU utilization                                              | Number | %
17//! `utilization<N>` | Utilization of Nth logical CPU                                       | Number | %
18//! `barchart`       | Utilization of all logical CPUs presented as a barchart              | Text   | -
19//! `frequency`      | Average CPU frequency (may be absent if CPU is not supported)        | Number | Hz
20//! `frequency<N>`   | Frequency of Nth logical CPU (may be absent if CPU is not supported) | Number | Hz
21//! `max_frequency`  | Max frequency of all logical CPUs                                    | Number | Hz
22//! `boost`          | CPU turbo boost status (may be absent if CPU is not supported)       | Text   | -
23//!
24//! Action          | Description                               | Default button
25//! ----------------|-------------------------------------------|---------------
26//! `toggle_format` **DEPRECATED** | Toggles between `format` and `format_alt` | -
27//! `next_format`  | Switches to the next format in the list     | Left
28//! `prev_format`  | Switches to the previous format in the list | Right
29//!
30//! # Example
31//!
32//! ```toml
33//! [[block]]
34//! block = "cpu"
35//! interval = 1
36//! format = " $icon $barchart $utilization "
37//! format_alt = " $icon $frequency{ $boost|} "
38//! info_cpu = 20
39//! warning_cpu = 50
40//! critical_cpu = 90
41//! ```
42//!
43//! # Icons Used
44//! - `cpu` (`$icon`, as a progression)
45//! - `cpu_boost_on` (`$boost`)
46//! - `cpu_boost_off` (`$boost`)
47
48use std::str::FromStr as _;
49
50use tokio::fs::File;
51use tokio::io::{AsyncBufReadExt as _, BufReader};
52
53use super::prelude::*;
54use crate::util::read_file;
55
56const CPU_BOOST_PATH: &str = "/sys/devices/system/cpu/cpufreq/boost";
57const CPU_NO_TURBO_PATH: &str = "/sys/devices/system/cpu/intel_pstate/no_turbo";
58
59#[derive(Deserialize, Debug, SmartDefault)]
60#[serde(default)]
61pub struct Config {
62    #[serde(flatten)]
63    pub formats: MaybeMultiFormatConfig,
64    #[default(5.into())]
65    pub interval: Seconds,
66    #[default(30.0)]
67    pub info_cpu: f64,
68    #[default(60.0)]
69    pub warning_cpu: f64,
70    #[default(90.0)]
71    pub critical_cpu: f64,
72}
73
74/// Every icon name [`boost_icon`] can return. The boost status is externally
75/// selected but finite, so the block plan declares the full set.
76const BOOST_ICON_NAMES: [&str; 2] = [icons::CPU_BOOST_ON, icons::CPU_BOOST_OFF];
77
78fn boost_icon(on: bool) -> &'static str {
79    match on {
80        true => icons::CPU_BOOST_ON,
81        false => icons::CPU_BOOST_OFF,
82    }
83}
84
85pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
86    // `icon`, `barchart` and `utilization` are computed on every update.
87    // `frequency`/`max_frequency` (CPU support), `boost` (sysfs support) and
88    // the per-core `utilizationN`/`frequencyN` values are conditional or
89    // dynamically named, so they stay undeclared.
90    let declare = |output: OutputPlan| {
91        output
92            .icon("icon", IconChoices::one(icons::CPU))
93            .icon("boost", IconChoices::fixed(BOOST_ICON_NAMES))
94    };
95    let formats = config.formats.with_default(" $icon $utilization ")?;
96    BlockPlan::new(format_outputs(formats, declare))
97}
98
99pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
100    let mut actions = api.get_actions()?;
101    api.set_default_actions(&[
102        (MouseButton::Left, None, "next_format"),
103        (MouseButton::Right, None, "prev_format"),
104    ])?;
105
106    let mut formats = FormatRotation::new(plan)?;
107
108    // Store previous /proc/stat state
109    let mut cputime = read_proc_stat().await?;
110    let cores = cputime.1.len();
111
112    if cores == 0 {
113        return Err(Error::new("/proc/stat reported zero cores"));
114    }
115
116    let mut timer = config.interval.timer();
117
118    loop {
119        let freqs = read_frequencies().await?;
120
121        // Compute utilizations
122        let new_cputime = read_proc_stat().await?;
123        let utilization_avg = new_cputime.0.utilization(cputime.0);
124        let mut utilizations = Vec::new();
125        if new_cputime.1.len() != cores {
126            return Err(Error::new("new cputime length is incorrect"));
127        }
128        for i in 0..cores {
129            utilizations.push(new_cputime.1[i].utilization(cputime.1[i]));
130        }
131        cputime = new_cputime;
132
133        // Create barchart indicating per-core utilization
134        let mut barchart = String::new();
135        const BOXCHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
136        for utilization in &utilizations {
137            barchart.push(BOXCHARS[(7.5 * utilization) as usize]);
138        }
139
140        // Read boost state on intel CPUs
141        let boost = boost_status().await.map(boost_icon);
142
143        let output = formats.current();
144
145        let mut values = map!(
146            "icon" => Value::icon_progression(icons::CPU, utilization_avg),
147            "barchart" => Value::text(barchart),
148            "utilization" => Value::percents(utilization_avg * 100.),
149            [if !freqs.is_empty()] "frequency" => Value::hertz(freqs.iter().sum::<f64>() / (freqs.len() as f64)),
150            [if !freqs.is_empty()] "max_frequency" => Value::hertz(freqs.iter().copied().max_by(f64::total_cmp).unwrap()),
151        );
152        if let Some(boost) = boost {
153            values.insert("boost".into(), Value::icon(boost));
154        }
155        for (i, freq) in freqs.iter().enumerate() {
156            values.insert(format!("frequency{}", i + 1).into(), Value::hertz(*freq));
157        }
158        for (i, utilization) in utilizations.iter().enumerate() {
159            values.insert(
160                format!("utilization{}", i + 1).into(),
161                Value::percents(utilization * 100.),
162            );
163        }
164
165        let mut widget = output.new_widget();
166        widget.set_values(values);
167        widget.state = match utilization_avg * 100. {
168            x if x > config.critical_cpu => State::Critical,
169            x if x > config.warning_cpu => State::Warning,
170            x if x > config.info_cpu => State::Info,
171            _ => State::Idle,
172        };
173        api.set_widget(widget)?;
174
175        loop {
176            select! {
177                _ = timer.tick() => break,
178                _ = api.wait_for_update_request() => break,
179                Some(action) = actions.recv() => match action.as_ref() {
180                    "next_format" | "toggle_format" => {
181                        formats.next();
182                        break;
183                    }
184                    "prev_format" => {
185                        formats.prev();
186                        break;
187                    }
188                    _ => (),
189                }
190            }
191        }
192    }
193}
194
195// Read frequencies (read in MHz, store in Hz)
196async fn read_frequencies() -> Result<Vec<f64>> {
197    let mut freqs = Vec::with_capacity(32);
198
199    let file = File::open("/proc/cpuinfo")
200        .await
201        .error("failed to read /proc/cpuinfo")?;
202    let mut file = BufReader::new(file);
203
204    let mut line = String::new();
205    while file
206        .read_line(&mut line)
207        .await
208        .error("failed to read /proc/cpuinfo")?
209        != 0
210    {
211        if line.starts_with("cpu MHz") {
212            let slice = line
213                .trim_end()
214                .trim_start_matches(|c: char| !c.is_ascii_digit());
215            freqs.push(f64::from_str(slice).error("failed to parse /proc/cpuinfo")? * 1e6);
216        }
217        line.clear();
218    }
219
220    Ok(freqs)
221}
222
223#[derive(Debug, Clone, Copy)]
224struct CpuTime {
225    idle: u64,
226    non_idle: u64,
227}
228
229impl CpuTime {
230    fn from_str(s: &str) -> Option<Self> {
231        let mut s = s.trim().split_ascii_whitespace();
232        let user = u64::from_str(s.next()?).ok()?;
233        let nice = u64::from_str(s.next()?).ok()?;
234        let system = u64::from_str(s.next()?).ok()?;
235        let idle = u64::from_str(s.next()?).ok()?;
236        let iowait = u64::from_str(s.next()?).ok()?;
237        let irq = u64::from_str(s.next()?).ok()?;
238        let softirq = u64::from_str(s.next()?).ok()?;
239
240        Some(Self {
241            idle: idle + iowait,
242            non_idle: user + nice + system + irq + softirq,
243        })
244    }
245
246    fn utilization(&self, old: Self) -> f64 {
247        let elapsed = (self.idle + self.non_idle).saturating_sub(old.idle + old.non_idle);
248        if elapsed == 0 {
249            0.0
250        } else {
251            ((self.non_idle - old.non_idle) as f64 / elapsed as f64).clamp(0., 1.)
252        }
253    }
254}
255
256async fn read_proc_stat() -> Result<(CpuTime, Vec<CpuTime>)> {
257    let mut utilizations = Vec::with_capacity(32);
258    let mut total = None;
259
260    let file = File::open("/proc/stat")
261        .await
262        .error("failed to read /proc/stat")?;
263    let mut file = BufReader::new(file);
264
265    let mut line = String::new();
266    while file
267        .read_line(&mut line)
268        .await
269        .error("failed to read /proc/stat")?
270        != 0
271    {
272        // Total time
273        let data = line.trim_start_matches(|c: char| !c.is_ascii_whitespace());
274        if line.starts_with("cpu ") {
275            total = Some(CpuTime::from_str(data).error("failed to parse /proc/stat")?);
276        } else if line.starts_with("cpu") {
277            utilizations.push(CpuTime::from_str(data).error("failed to parse /proc/stat")?);
278        }
279        line.clear();
280    }
281
282    Ok((total.error("failed to parse /proc/stat")?, utilizations))
283}
284
285/// Read the cpu turbo boost status from kernel sys interface
286/// or intel pstate interface
287async fn boost_status() -> Option<bool> {
288    if let Ok(boost) = read_file(CPU_BOOST_PATH).await {
289        Some(boost.starts_with('1'))
290    } else if let Ok(no_turbo) = read_file(CPU_NO_TURBO_PATH).await {
291        Some(no_turbo.starts_with('0'))
292    } else {
293        None
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn plan_declares_icon_and_boost_choices() {
303        let plan = prepare(&Config::default()).unwrap();
304        let format = plan.output("format").unwrap();
305        assert_eq!(format.single_icon("icon").unwrap(), "cpu");
306        let boost = format.output().choices_for("boost").unwrap();
307        assert!(boost.permits("cpu_boost_on"));
308        assert!(boost.permits("cpu_boost_off"));
309        assert!(!boost.permits("cpu"));
310        // One format configured, so there is nothing to rotate to.
311        assert!(plan.output("format2").is_err());
312    }
313
314    #[test]
315    fn every_configured_format_becomes_an_output() {
316        let config: Config =
317            toml::from_str(r#"format = [" $icon ", " $icon $frequency "]"#).unwrap();
318        let plan = prepare(&config).unwrap();
319        let second = plan.output("format2").unwrap();
320        assert!(second.format().contains_key("frequency"));
321        // Every format the user can rotate to carries the same contract.
322        assert_eq!(second.single_icon("icon").unwrap(), "cpu");
323        assert!(
324            second
325                .output()
326                .choices_for("boost")
327                .unwrap()
328                .permits("cpu_boost_off")
329        );
330    }
331
332    #[test]
333    fn every_boost_icon_is_declared() {
334        for on in [true, false] {
335            assert!(BOOST_ICON_NAMES.contains(&boost_icon(on)));
336        }
337        assert_eq!(BOOST_ICON_NAMES.len(), 2);
338    }
339
340    #[test]
341    fn legacy_format_alt_still_declares_both_formats() {
342        // Upstream kept `format` + `format_alt` working alongside the new
343        // list form, so both spellings must yield two outputs.
344        let split: Config =
345            toml::from_str("format = \" $icon \"\nformat_alt = \" $icon $frequency \"").unwrap();
346        let list: Config = toml::from_str(r#"format = [" $icon ", " $icon $frequency "]"#).unwrap();
347        for config in [split, list] {
348            let plan = prepare(&config).unwrap();
349            let ids: Vec<_> = plan.outputs().map(|o| o.id().to_string()).collect();
350            assert_eq!(ids, ["format", "format2"]);
351            assert!(
352                plan.output("format2")
353                    .unwrap()
354                    .format()
355                    .contains_key("frequency")
356            );
357        }
358    }
359}