1use 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
172fn 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
183fn 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
192fn 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
219fn 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
229fn 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
252fn 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
258fn 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}