Skip to main content

i3status_rs/blocks/
battery.rs

1//! Information about the internal power supply
2//!
3//! This block can display the current battery state (Full, Charging or Discharging), percentage
4//! charged and estimate time until (dis)charged for an internal power supply.
5//!
6//! # Configuration
7//!
8//! Key | Values | Default
9//! ----|--------|--------
10//! `device` | sysfs/UPower: The device in `/sys/class/power_supply/` to read from (can also be "DisplayDevice" for UPower, which is a single logical power source representing all physical power sources. This is for example useful if your system has multiple batteries, in which case the DisplayDevice behaves as if you had a single larger battery.). apc_ups: IPv4Address:port or hostname:port | sysfs: the first battery device found in /sys/class/power_supply, with "BATx" or "CMBx" entries taking precedence. apc_ups: "localhost:3551". upower: `DisplayDevice`
11//! `driver` | One of `"sysfs"`, `"apc_ups"`, or `"upower"` | `"sysfs"`
12//! `model` | If present, the contents of `/sys/class/power_supply/.../model_name` must match this value. Typical use is to select by model name on devices that change their path. | N/A
13//! `interval` | Update interval, in seconds. Only relevant for driver = "sysfs" or "apc_ups". | `10`
14//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $percentage "`
15//! `full_format` | Same as `format` but for when the battery is full | `" $icon "`
16//! `charging_format` | Same as `format` but for when the battery is charging | Links to `format`
17//! `empty_format` | Same as `format` but for when the battery is empty | `" $icon "`
18//! `not_charging_format` | Same as `format` but for when the battery is not charging. Defaults to the full battery icon as many batteries report this status when they are full. | `" $icon "`
19//! `missing_format` | Same as `format` if the battery cannot be found. | `" $icon "`
20//! `info` | Minimum battery level, where state is set to info | `60`
21//! `good` | Minimum battery level, where state is set to good | `60`
22//! `warning` | Minimum battery level, where state is set to warning | `30`
23//! `critical` | Minimum battery level, where state is set to critical | `15`
24//! `full_threshold` | Percentage above which the battery is considered full (`full_format` shown) | `95`
25//! `empty_threshold` | Percentage below which the battery is considered empty | `7.5`
26//!
27//! Placeholder  | Value                                                                   | Type              | Unit
28//! -------------|-------------------------------------------------------------------------|-------------------|-----
29//! `icon`       | Icon based on battery's state                                           | Icon   | -
30//! `percentage` | Battery level, in percent                                               | Number | Percents
31//! `time_remaining`  | Time remaining until (dis)charge is complete. Presented only if battery's status is (dis)charging. | Duration | -
32//! `time`       | Time remaining until (dis)charge is complete. Presented only if battery's status is (dis)charging. | String *DEPRECATED* | -
33//! `power`      | Power consumption by the battery or from the power supply when charging | String or Float   | Watts
34//!
35//! `time` has been deprecated in favor of `time_remaining`.
36//!
37//! # Examples
38//!
39//! Basic usage:
40//!
41//! ```toml
42//! [[block]]
43//! block = "battery"
44//! format = " $icon $percentage "
45//! ```
46//!
47//! ```toml
48//! [[block]]
49//! block = "battery"
50//! format = " $percentage {$time_remaining.dur(hms:true, min_unit:m) |}"
51//! device = "DisplayDevice"
52//! driver = "upower"
53//! ```
54//!
55//! Hide missing battery:
56//!
57//! ```toml
58//! [[block]]
59//! block = "battery"
60//! missing_format = ""
61//! ```
62//!
63//! # Icons Used
64//! - `bat` (`$icon`, in `format` `full_format` `empty_format` `not_charging_format`, as a progression)
65//! - `bat_charging` (`$icon`, in `charging_format`, as a progression)
66//! - `bat_not_available` (`$icon`, in `missing_format`)
67
68use regex::Regex;
69use std::convert::Infallible;
70use std::str::FromStr;
71
72use super::prelude::*;
73
74mod apc_ups;
75mod sysfs;
76mod upower;
77
78// make_log_macro!(debug, "battery");
79
80#[derive(Deserialize, Debug, SmartDefault)]
81#[serde(deny_unknown_fields, default)]
82pub struct Config {
83    pub device: Option<String>,
84    pub driver: BatteryDriver,
85    pub model: Option<String>,
86    #[default(10.into())]
87    pub interval: Seconds,
88    pub format: FormatConfig,
89    pub full_format: FormatConfig,
90    pub charging_format: FormatConfig,
91    pub empty_format: FormatConfig,
92    pub not_charging_format: FormatConfig,
93    pub missing_format: FormatConfig,
94    #[default(60.0)]
95    pub info: f64,
96    #[default(60.0)]
97    pub good: f64,
98    #[default(30.0)]
99    pub warning: f64,
100    #[default(15.0)]
101    pub critical: f64,
102    #[default(95.0)]
103    pub full_threshold: f64,
104    #[default(7.5)]
105    pub empty_threshold: f64,
106}
107
108#[derive(Deserialize, Debug, SmartDefault)]
109#[serde(rename_all = "snake_case")]
110pub enum BatteryDriver {
111    #[default]
112    Sysfs,
113    ApcUps,
114    Upower,
115}
116
117pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
118    let format = config.format.with_default(" $icon $percentage ")?;
119    BlockPlan::new(vec![
120        OutputPlan::new("discharging", format.clone()).icon("icon", IconChoices::one(icons::BAT)),
121        OutputPlan::new(
122            "charging",
123            config.charging_format.with_default_format(&format),
124        )
125        .icon("icon", IconChoices::one(icons::BAT_CHARGING)),
126        OutputPlan::new("full", config.full_format.with_default(" $icon ")?)
127            .icon("icon", IconChoices::one(icons::BAT)),
128        OutputPlan::new("empty", config.empty_format.with_default(" $icon ")?)
129            .icon("icon", IconChoices::one(icons::BAT)),
130        OutputPlan::new(
131            "not_charging",
132            config.not_charging_format.with_default(" $icon ")?,
133        )
134        .icon("icon", IconChoices::one(icons::BAT)),
135        OutputPlan::new("missing", config.missing_format.with_default(" $icon ")?)
136            .icon("icon", IconChoices::one(icons::BAT_NOT_AVAILABLE)),
137    ])
138}
139
140pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
141    let output_discharging = plan.output("discharging")?;
142    let output_charging = plan.output("charging")?;
143    let output_full = plan.output("full")?;
144    let output_empty = plan.output("empty")?;
145    let output_not_charging = plan.output("not_charging")?;
146    let output_missing = plan.output("missing")?;
147
148    let dev_name = DeviceName::new(config.device.clone())?;
149    let mut device: Box<dyn BatteryDevice + Send + Sync> = match config.driver {
150        BatteryDriver::Sysfs => Box::new(sysfs::Device::new(
151            dev_name,
152            config.model.clone(),
153            config.interval,
154        )),
155        BatteryDriver::ApcUps => Box::new(apc_ups::Device::new(dev_name, config.interval).await?),
156        BatteryDriver::Upower => {
157            Box::new(upower::Device::new(dev_name, config.model.clone()).await?)
158        }
159    };
160
161    loop {
162        let mut info = device.get_info().await?;
163
164        if let Some(info) = &mut info {
165            if info.capacity >= config.full_threshold {
166                info.status = BatteryStatus::Full;
167            } else if info.capacity <= config.empty_threshold
168                && info.status != BatteryStatus::Charging
169            {
170                info.status = BatteryStatus::Empty;
171            }
172        }
173
174        match info {
175            Some(info) => {
176                let output = match info.status {
177                    BatteryStatus::Empty => &output_empty,
178                    BatteryStatus::Full => &output_full,
179                    BatteryStatus::Charging => &output_charging,
180                    BatteryStatus::NotCharging => &output_not_charging,
181                    _ => &output_discharging,
182                };
183                let mut widget = output.new_widget();
184
185                let mut values = map!(
186                    "percentage" => Value::percents(info.capacity)
187                );
188
189                info.power
190                    .map(|p| values.insert("power".into(), Value::watts(p)));
191                info.time_remaining.inspect(|&t| {
192                    map! { @extend values
193                        "time" => Value::text(
194                            format!(
195                                "{}:{:02}",
196                                (t / 3600.) as i32,
197                                (t % 3600. / 60.) as i32
198                            ),
199                        ),
200                        "time_remaining" =>  Value::duration(
201                            Duration::from_secs(t as u64),
202                        ),
203                    }
204                });
205
206                let (icon_name, icon_value, state) = match (info.status, info.capacity) {
207                    (BatteryStatus::Empty, _) => (icons::BAT, 0.0, State::Critical),
208                    (BatteryStatus::Full | BatteryStatus::NotCharging, _) => {
209                        (icons::BAT, 1.0, State::Idle)
210                    }
211                    (status, capacity) => (
212                        if status == BatteryStatus::Charging {
213                            icons::BAT_CHARGING
214                        } else {
215                            icons::BAT
216                        },
217                        capacity / 100.0,
218                        if status == BatteryStatus::Charging {
219                            State::Good
220                        } else if capacity <= config.critical {
221                            State::Critical
222                        } else if capacity <= config.warning {
223                            State::Warning
224                        } else if capacity <= config.info {
225                            State::Info
226                        } else if capacity > config.good {
227                            State::Good
228                        } else {
229                            State::Idle
230                        },
231                    ),
232                };
233
234                values.insert(
235                    "icon".into(),
236                    Value::icon_progression(icon_name, icon_value),
237                );
238
239                widget.set_values(values);
240                widget.state = state;
241                api.set_widget(widget)?;
242            }
243            None => {
244                let mut widget = output_missing.new_widget().with_state(State::Critical);
245                widget.set_values(map!("icon" => Value::icon(icons::BAT_NOT_AVAILABLE)));
246                api.set_widget(widget)?;
247            }
248        }
249
250        select! {
251            update = device.wait_for_change() => update?,
252            _ = api.wait_for_update_request() => (),
253        }
254    }
255}
256
257#[async_trait]
258trait BatteryDevice {
259    async fn get_info(&mut self) -> Result<Option<BatteryInfo>>;
260    async fn wait_for_change(&mut self) -> Result<()>;
261}
262
263/// `Option<Regex>`, but more intuitive
264#[derive(Debug)]
265enum DeviceName {
266    Any,
267    Regex(Regex),
268}
269
270impl DeviceName {
271    fn new(pat: Option<String>) -> Result<Self> {
272        Ok(match pat {
273            None => Self::Any,
274            Some(pat) => Self::Regex(pat.parse().error("failed to parse regex")?),
275        })
276    }
277
278    fn matches(&self, name: &str) -> bool {
279        match self {
280            Self::Any => true,
281            Self::Regex(pat) => pat.is_match(name),
282        }
283    }
284
285    fn exact(&self) -> Option<&str> {
286        match self {
287            Self::Any => None,
288            Self::Regex(pat) => Some(pat.as_str()),
289        }
290    }
291}
292
293#[derive(Debug, Clone, Copy)]
294struct BatteryInfo {
295    /// Current status, e.g. "charging", "discharging", etc.
296    status: BatteryStatus,
297    /// The capacity in percents
298    capacity: f64,
299    /// Power consumption in watts
300    power: Option<f64>,
301    /// Time in seconds
302    time_remaining: Option<f64>,
303}
304
305#[derive(Copy, Clone, Debug, Eq, PartialEq, SmartDefault)]
306enum BatteryStatus {
307    Charging,
308    Discharging,
309    Empty,
310    Full,
311    NotCharging,
312    #[default]
313    Unknown,
314}
315
316impl FromStr for BatteryStatus {
317    type Err = Infallible;
318
319    fn from_str(s: &str) -> Result<Self, Self::Err> {
320        Ok(match s {
321            "Charging" => Self::Charging,
322            "Discharging" => Self::Discharging,
323            "Empty" => Self::Empty,
324            "Full" => Self::Full,
325            "Not charging" => Self::NotCharging,
326            _ => Self::Unknown,
327        })
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn charging_format_inherits_configured_format() {
337        // With no explicit charging_format, charging must render whatever
338        // `format` resolved to — including a format with no $icon.
339        let config = Config {
340            format: " $percentage ".parse().unwrap(),
341            ..Config::default()
342        };
343        let plan = prepare(&config).unwrap();
344        assert!(
345            !plan
346                .output("charging")
347                .unwrap()
348                .format()
349                .contains_key("icon")
350        );
351
352        let plan = prepare(&Config::default()).unwrap();
353        assert!(
354            plan.output("charging")
355                .unwrap()
356                .format()
357                .contains_key("icon")
358        );
359    }
360
361    #[test]
362    fn explicit_charging_format_wins_over_inheritance() {
363        let config = Config {
364            format: " $percentage ".parse().unwrap(),
365            charging_format: " $icon ".parse().unwrap(),
366            ..Config::default()
367        };
368        let plan = prepare(&config).unwrap();
369        assert!(
370            plan.output("charging")
371                .unwrap()
372                .format()
373                .contains_key("icon")
374        );
375    }
376
377    #[test]
378    fn icons_are_state_specific() {
379        let plan = prepare(&Config::default()).unwrap();
380        for (id, icon) in [
381            ("discharging", "bat"),
382            ("charging", "bat_charging"),
383            ("full", "bat"),
384            ("empty", "bat"),
385            ("not_charging", "bat"),
386            ("missing", "bat_not_available"),
387        ] {
388            assert_eq!(
389                plan.output(id).unwrap().single_icon("icon").unwrap(),
390                icon,
391                "{id}"
392            );
393        }
394    }
395}