1use regex::Regex;
69use std::convert::Infallible;
70use std::str::FromStr;
71
72use super::prelude::*;
73
74mod apc_ups;
75mod sysfs;
76mod upower;
77
78#[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#[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 status: BatteryStatus,
297 capacity: f64,
299 power: Option<f64>,
301 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 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}