rs_blocks/blocks/
battery.rs

1// Copyright ⓒ 2019-2021 Lewis Belcher
2// Licensed under the MIT license (see LICENSE or <http://opensource.org/licenses/MIT>).
3// All files in the project carrying such notice may not be copied, modified, or
4// distributed except according to those terms
5
6//! # Battery block
7//!
8//! Use this block to get battery monitoring in the status bar.
9//!
10//! Typical configuration:
11//!
12//! ```toml
13//! [battery]
14//! ```
15//!
16//! ## Configuration options
17//!
18//! - `name`: Name of the block (must be unique)
19//! - `period`: Default update period in seconds (extra updates may occur on
20//!    event changes etc)
21//! - `alpha`: Weight for the exponential moving average of value updates
22//! - `path_to_charge_now`: Path to file containing current charge (usually
23//!    something like `/sys/class/power_supply/BAT0/charge_now`)
24//! - `path_to_charge_full`: Path to file containing charge value when full
25//!    (usually something like `/sys/class/power_supply/BAT0/charge_full`)
26//! - `path_to_status`: Path to file containing battery status (usually
27//!    something like `/sys/class/power_supply/BAT0/status`)
28
29use crate::blocks::{Block, Configure, Message as BlockMessage, Sender, ValidatedPath};
30use crate::{ema, utils};
31use anyhow::Context;
32use serde::Deserialize;
33use std::fs;
34use std::thread;
35use std::time::Instant;
36
37#[derive(Configure, Deserialize)]
38pub struct Battery {
39	#[serde(default = "default_name")]
40	name: String,
41	#[serde(default = "default_period")]
42	period: f32,
43	#[serde(default = "default_alpha")]
44	alpha: f32,
45	#[serde(default = "default_path_to_charge_now")]
46	path_to_charge_now: ValidatedPath,
47	#[serde(default = "default_path_to_charge_full")]
48	path_to_charge_full: ValidatedPath,
49	#[serde(default = "default_path_to_status")]
50	path_to_status: ValidatedPath,
51}
52
53fn default_name() -> String {
54	"battery".to_string()
55}
56
57fn default_period() -> f32 {
58	0.6
59}
60
61fn default_alpha() -> f32 {
62	0.8
63}
64
65fn default_path_to_charge_now() -> ValidatedPath {
66	ValidatedPath("/sys/class/power_supply/BAT0/charge_now".to_string())
67}
68
69fn default_path_to_charge_full() -> ValidatedPath {
70	ValidatedPath("/sys/class/power_supply/BAT0/charge_full".to_string())
71}
72
73fn default_path_to_status() -> ValidatedPath {
74	ValidatedPath("/sys/class/power_supply/BAT0/status".to_string())
75}
76
77impl Sender for Battery {
78	fn add_sender(&self, channel: crossbeam_channel::Sender<BlockMessage>) -> anyhow::Result<()> {
79		let name = self.get_name();
80		let max = get_max_capacity(&self.path_to_charge_full.0)?;
81		let (tx, rx) = crossbeam_channel::unbounded();
82		let mut sremain = "...".to_string();
83		let mut last_status_change = 0;
84		let mut remaining = ema::Ema::new(self.alpha);
85		let (mut current_charge, mut current_status) = initialise(
86			&self.path_to_charge_now.0,
87			&self.path_to_status.0,
88			self.period,
89			tx,
90		)?;
91		let mut then = Instant::now();
92		let mut fraction = (current_charge / max).min(1.0);
93		let mut block = Block::new(name.clone(), true);
94		let mut symbol = get_symbol(current_status, fraction);
95
96		if current_status == Status::Full {
97			block.full_text = Some(create_full_text(&symbol, fraction, "Full"));
98			channel.send((name.clone(), block.to_string())).unwrap();
99		}
100
101		thread::spawn(move || loop {
102			let message = rx.recv().unwrap();
103			log::debug!("{:?}", message);
104			let now = Instant::now();
105
106			match message {
107				Message::Charge(charge) => {
108					sremain = if last_status_change == 0 {
109						remaining.reset();
110						"...".to_string()
111					} else {
112						let elapsed = now.duration_since(then).as_secs() as f32 / 60.0;
113						let gap = match current_status {
114							Status::Charging => max - charge,
115							Status::Discharging => charge,
116							Status::Full => 0.0,
117							Status::Unknown => charge,
118						};
119						if charge == current_charge {
120							continue;
121						}
122						let rate = (charge - current_charge).abs() / elapsed;
123						log::info!("rate = {}", rate);
124						minutes_to_string(remaining.push(gap / rate))
125					};
126
127					then = now;
128					current_charge = charge;
129					last_status_change += 1;
130					fraction = (current_charge / max).min(1.0);
131				}
132				Message::Status(status) => {
133					if status != current_status {
134						last_status_change = 0;
135						current_status = status;
136						sremain = "...".to_string();
137					}
138				}
139			}
140			if current_status == Status::Full {
141				sremain = "Full".to_string();
142			}
143			symbol = get_symbol(current_status, fraction);
144
145			block.full_text = Some(create_full_text(&symbol, fraction, &sremain));
146			channel.send((name.clone(), block.to_string())).unwrap();
147		});
148
149		Ok(())
150	}
151}
152
153fn get_max_capacity(path: &str) -> anyhow::Result<f32> {
154	let contents = fs::read_to_string(&path).context(format!("Could not read path '{}'", path))?;
155	utils::str_to_f32(&contents).context(format!("Could not parse contents of '{}'", path))
156}
157
158#[derive(Debug, Clone, Copy, PartialEq)]
159enum Status {
160	Charging,
161	Discharging,
162	Full,
163	Unknown,
164}
165
166#[derive(Debug, Clone, Copy)]
167enum Message {
168	Charge(f32),
169	Status(Status),
170}
171
172/// Convert a string to a status.
173fn str_to_status(s: &str) -> anyhow::Result<Message> {
174	match s.trim() {
175		"Charging" => Ok(Message::Status(Status::Charging)),
176		"Discharging" => Ok(Message::Status(Status::Discharging)),
177		"Full" => Ok(Message::Status(Status::Full)),
178		"Unknown" => Ok(Message::Status(Status::Unknown)),
179		_ => anyhow::bail!("Unknown status {}", s),
180	}
181}
182
183/// Convert a string to a charge enum.
184fn str_to_charge(s: &str) -> anyhow::Result<Message> {
185	let value = s
186		.trim()
187		.parse()
188		.context(format!("Unexpected value for charge '{}'", s))?;
189	Ok(Message::Charge(value))
190}
191
192/// Continuously monitor `f` for changes, when a change occurs or more than 10
193/// checks have occurred, pipe its contents through `parse_fn` and send the
194/// results over the sender `tx`.
195fn looper<F, T>(tx: crossbeam_channel::Sender<Message>, mut f: utils::Monitor<T>, parse_fn: F)
196where
197	F: 'static + Fn(&str) -> anyhow::Result<Message> + Send,
198	T: 'static + FnMut() -> String + Send,
199{
200	thread::spawn(move || {
201		let mut prev = f.read();
202		let mut i = 0;
203
204		for contents in f {
205			if contents != prev || i > 10 {
206				let parsed = parse_fn(&contents).expect(&format!(
207					"Encountered bad value in battery file: '{}'",
208					contents
209				));
210				tx.send(parsed).unwrap();
211				prev = contents;
212				i = 0;
213			}
214			i += 1;
215		}
216	});
217}
218
219/// Given a percentage of charge, wrap the string `s` in an appropriate colour.
220fn wrap_in_colour(s: &str, fraction: f32) -> String {
221	let colour = if fraction > 0.5 {
222		format!("{:0>2x}ff00", 255 - (510.0 * (fraction - 0.5)) as i32)
223	} else {
224		format!("ff{:0>2x}00", (510.0 * fraction) as i32)
225	};
226	format!("<span foreground='#{}'>{}</span>", colour, s)
227}
228
229/// Given a percentage of charge, return an appropriate battery symbol.
230fn get_discharge_symbol(fraction: f32) -> &'static str {
231	if fraction > 0.90 {
232		" "
233	} else if fraction > 0.60 {
234		" "
235	} else if fraction > 0.40 {
236		" "
237	} else if fraction > 0.10 {
238		" "
239	} else {
240		" "
241	}
242}
243
244fn get_symbol(status: Status, fraction: f32) -> String {
245	let s = match status {
246		Status::Discharging => get_discharge_symbol(fraction),
247		_ => " ",
248	};
249	wrap_in_colour(s, fraction)
250}
251
252/// Convert a float of minutes into a string of hours and minutes.
253fn minutes_to_string(remain: f32) -> String {
254	let (hrs, mins) = (remain / 60.0, remain % 60.0);
255	format!("{:.0}h{:02.0}m", hrs.floor(), mins)
256}
257
258/// Start watching the appropriate files for changes and return their current
259/// contents.
260fn initialise(
261	path_to_charge_now: &str,
262	path_to_status: &str,
263	period: f32,
264	tx: crossbeam_channel::Sender<Message>,
265) -> anyhow::Result<(f32, Status)> {
266	let mut charge_file = utils::monitor_file(path_to_charge_now.to_string(), period);
267	let mut status_file = utils::monitor_file(path_to_status.to_string(), period);
268
269	let current_charge = match str_to_charge(&charge_file.read())? {
270		Message::Charge(value) => value,
271		_ => unreachable!(),
272	};
273
274	let current_status = match str_to_status(&status_file.read())? {
275		Message::Status(value) => value,
276		_ => unreachable!(),
277	};
278
279	looper(tx.clone(), charge_file, str_to_charge);
280	looper(tx.clone(), status_file, str_to_status);
281
282	Ok((current_charge, current_status))
283}
284
285fn create_full_text(symbol: &str, fraction: f32, remaining: &str) -> String {
286	format!("{}{:.0}% ({})", symbol, fraction * 100.0, remaining)
287}
288
289#[cfg(test)]
290mod test {
291	use super::*;
292
293	#[test]
294	fn minutes_to_string_works() {
295		assert_eq!(minutes_to_string(302.2), "5h02m");
296		assert_eq!(minutes_to_string(302.7), "5h03m");
297	}
298
299	#[test]
300	fn test_wrap_in_colour() {
301		let result = wrap_in_colour("a", 1.0);
302		assert_eq!(result, "<span foreground=\'#00ff00\'>a</span>");
303
304		let result = wrap_in_colour("a", 0.01);
305		assert_eq!(result, "<span foreground=\'#ff0500\'>a</span>");
306	}
307}