1use std::fmt;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28#[derive(Debug)]
32pub enum BatteryError {
33 NotFound,
35 Io(std::io::Error),
37 Parse(String),
39 Unsupported(&'static str),
41}
42
43impl fmt::Display for BatteryError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 BatteryError::NotFound => write!(f, "no battery device found"),
47 BatteryError::Io(e) => write!(f, "I/O error: {e}"),
48 BatteryError::Parse(msg) => write!(f, "parse error: {msg}"),
49 BatteryError::Unsupported(platform) => {
50 write!(f, "battery reading not supported on {platform}")
51 }
52 }
53 }
54}
55
56impl std::error::Error for BatteryError {
57 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
58 match self {
59 BatteryError::Io(e) => Some(e),
60 _ => None,
61 }
62 }
63}
64
65impl From<std::io::Error> for BatteryError {
66 fn from(e: std::io::Error) -> Self {
67 BatteryError::Io(e)
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum BatteryStatus {
76 Charging,
78 Discharging,
80 Full,
82 NotCharging,
84 Unknown,
86}
87
88impl BatteryStatus {
89 pub fn from_sysfs(s: &str) -> Self {
91 match s.trim() {
92 "Charging" => BatteryStatus::Charging,
93 "Discharging" => BatteryStatus::Discharging,
94 "Full" => BatteryStatus::Full,
95 "Not charging" => BatteryStatus::NotCharging,
96 _ => BatteryStatus::Unknown,
97 }
98 }
99
100 pub fn sysfs_str(self) -> &'static str {
104 match self {
105 BatteryStatus::Charging => "Charging",
106 BatteryStatus::Discharging => "Discharging",
107 BatteryStatus::Full => "Full",
108 BatteryStatus::NotCharging => "Not charging",
109 BatteryStatus::Unknown => "Unknown",
110 }
111 }
112}
113
114impl fmt::Display for BatteryStatus {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
118 BatteryStatus::Charging => write!(f, "charging"),
119 BatteryStatus::Discharging => write!(f, "discharging"),
120 BatteryStatus::Full => write!(f, "full"),
121 BatteryStatus::NotCharging => write!(f, "not charging"),
122 BatteryStatus::Unknown => write!(f, "unknown"),
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
131pub struct BatteryReading {
132 pub percent: u8,
134 pub status: BatteryStatus,
136 pub time_to_empty: Option<Duration>,
141}
142
143pub trait BatteryReader: Send + Sync {
151 fn read(&self) -> Result<BatteryReading, BatteryError>;
153}
154
155pub struct LinuxSysfsReader {
164 root: PathBuf,
165}
166
167impl LinuxSysfsReader {
168 pub fn new() -> Self {
170 Self {
171 root: PathBuf::from("/sys/class/power_supply"),
172 }
173 }
174
175 pub fn with_root(root: impl Into<PathBuf>) -> Self {
179 Self { root: root.into() }
180 }
181
182 fn find_battery_dir(&self) -> Result<PathBuf, BatteryError> {
183 let entries = std::fs::read_dir(&self.root).map_err(|e| {
184 if e.kind() == std::io::ErrorKind::NotFound {
185 BatteryError::NotFound
186 } else {
187 BatteryError::Io(e)
188 }
189 })?;
190
191 let mut candidates: Vec<PathBuf> = entries
192 .filter_map(|e| e.ok())
193 .map(|e| e.path())
194 .filter(|p| {
195 let type_file = p.join("type");
196 std::fs::read_to_string(&type_file)
197 .map(|s| s.trim() == "Battery")
198 .unwrap_or(false)
199 })
200 .collect();
201
202 candidates.sort();
204 candidates.into_iter().next().ok_or(BatteryError::NotFound)
205 }
206
207 fn read_u64(path: &Path) -> Option<u64> {
208 std::fs::read_to_string(path)
209 .ok()
210 .and_then(|s| s.trim().parse::<u64>().ok())
211 }
212
213 fn compute_time_to_empty(bat_dir: &Path) -> Option<Duration> {
214 let energy_now = Self::read_u64(&bat_dir.join("energy_now"));
216 let power_now = Self::read_u64(&bat_dir.join("power_now"));
217
218 if let (Some(e), Some(p)) = (energy_now, power_now)
219 && let Some(secs) = (e * 3600).checked_div(p)
220 {
221 return Some(Duration::from_secs(secs));
222 }
223
224 let charge_now = Self::read_u64(&bat_dir.join("charge_now"));
226 let current_now = Self::read_u64(&bat_dir.join("current_now"));
227
228 if let (Some(c), Some(i)) = (charge_now, current_now)
229 && let Some(secs) = (c * 3600).checked_div(i)
230 {
231 return Some(Duration::from_secs(secs));
232 }
233
234 None
235 }
236}
237
238impl Default for LinuxSysfsReader {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244impl BatteryReader for LinuxSysfsReader {
245 fn read(&self) -> Result<BatteryReading, BatteryError> {
246 let bat_dir = self.find_battery_dir()?;
247
248 let capacity_str =
250 std::fs::read_to_string(bat_dir.join("capacity")).map_err(BatteryError::Io)?;
251 let percent = capacity_str
252 .trim()
253 .parse::<u8>()
254 .map_err(|e| BatteryError::Parse(format!("capacity: {e}")))?;
255
256 let status_str =
258 std::fs::read_to_string(bat_dir.join("status")).map_err(BatteryError::Io)?;
259 let status = BatteryStatus::from_sysfs(&status_str);
260
261 let time_to_empty = if status == BatteryStatus::Discharging {
263 Self::compute_time_to_empty(&bat_dir)
264 } else {
265 None
266 };
267
268 Ok(BatteryReading {
269 percent,
270 status,
271 time_to_empty,
272 })
273 }
274}
275
276pub struct UnsupportedReader {
282 pub platform: &'static str,
284}
285
286impl BatteryReader for UnsupportedReader {
287 fn read(&self) -> Result<BatteryReading, BatteryError> {
288 Err(BatteryError::Unsupported(self.platform))
289 }
290}
291
292pub fn default_reader() -> Box<dyn BatteryReader> {
299 #[cfg(target_os = "linux")]
300 {
301 Box::new(LinuxSysfsReader::new())
302 }
303 #[cfg(not(target_os = "linux"))]
304 {
305 #[cfg(target_os = "macos")]
306 let platform = "macos";
307 #[cfg(target_os = "windows")]
308 let platform = "windows";
309 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
310 let platform = "this platform";
311 Box::new(UnsupportedReader { platform })
312 }
313}
314
315pub struct MockBatteryReader {
321 pub reading: Result<BatteryReading, BatteryError>,
323}
324
325impl BatteryReader for MockBatteryReader {
326 fn read(&self) -> Result<BatteryReading, BatteryError> {
327 match &self.reading {
328 Ok(r) => Ok(r.clone()),
329 Err(BatteryError::NotFound) => Err(BatteryError::NotFound),
330 Err(BatteryError::Unsupported(p)) => Err(BatteryError::Unsupported(p)),
331 Err(BatteryError::Parse(s)) => Err(BatteryError::Parse(s.clone())),
332 Err(BatteryError::Io(e)) => Err(BatteryError::Io(std::io::Error::new(
335 e.kind(),
336 e.to_string(),
337 ))),
338 }
339 }
340}
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::fs;
348 use tempfile::TempDir;
349
350 struct FakeSysfs {
353 dir: TempDir,
354 }
355
356 impl FakeSysfs {
357 fn new() -> Self {
358 Self {
359 dir: TempDir::new().expect("create tempdir"),
360 }
361 }
362
363 fn add_entry(&self, name: &str, type_str: &str) -> PathBuf {
365 let entry_dir = self.dir.path().join(name);
366 fs::create_dir_all(&entry_dir).expect("create entry dir");
367 fs::write(entry_dir.join("type"), type_str).expect("write type");
368 entry_dir
369 }
370
371 fn add_battery(&self, name: &str, capacity: u8, status: &str) -> PathBuf {
373 let d = self.add_entry(name, "Battery\n");
374 fs::write(d.join("capacity"), format!("{capacity}\n")).expect("write capacity");
375 fs::write(d.join("status"), format!("{status}\n")).expect("write status");
376 d
377 }
378
379 fn reader(&self) -> LinuxSysfsReader {
380 LinuxSysfsReader::with_root(self.dir.path())
381 }
382 }
383
384 #[test]
387 fn status_from_sysfs_round_trip() {
388 let cases = [
389 ("Charging", BatteryStatus::Charging, "Charging", "charging"),
390 (
391 "Discharging",
392 BatteryStatus::Discharging,
393 "Discharging",
394 "discharging",
395 ),
396 ("Full", BatteryStatus::Full, "Full", "full"),
397 (
398 "Not charging",
399 BatteryStatus::NotCharging,
400 "Not charging",
401 "not charging",
402 ),
403 ("Unknown", BatteryStatus::Unknown, "Unknown", "unknown"),
404 ("Bogus", BatteryStatus::Unknown, "Unknown", "unknown"),
405 ];
406
407 for (input, expected_variant, expected_sysfs, expected_display) in cases {
408 let parsed = BatteryStatus::from_sysfs(input);
409 assert_eq!(parsed, expected_variant, "from_sysfs({input:?})");
410 assert_eq!(
411 parsed.sysfs_str(),
412 expected_sysfs,
413 "sysfs_str() for {input:?}"
414 );
415 assert_eq!(
416 parsed.to_string(),
417 expected_display,
418 "Display for {input:?}"
419 );
420 }
421 }
422
423 #[test]
426 fn reads_basic_battery_info() {
427 let fs = FakeSysfs::new();
428 fs.add_battery("BAT0", 72, "Discharging");
429
430 let reading = fs.reader().read().expect("read ok");
431 assert_eq!(reading.percent, 72);
432 assert_eq!(reading.status, BatteryStatus::Discharging);
433 }
434
435 #[test]
438 fn skips_non_battery_entry() {
439 let fs = FakeSysfs::new();
440 fs.add_entry("ACAD", "Mains\n");
442 fs.add_battery("BAT1", 55, "Charging");
443
444 let reading = fs.reader().read().expect("read ok");
445 assert_eq!(reading.percent, 55);
446 assert_eq!(reading.status, BatteryStatus::Charging);
447 }
448
449 #[test]
452 fn picks_bat0_before_bat1() {
453 let fs = FakeSysfs::new();
454 fs.add_battery("BAT1", 10, "Discharging");
455 fs.add_battery("BAT0", 90, "Charging");
456
457 let reading = fs.reader().read().expect("read ok");
458 assert_eq!(reading.percent, 90);
460 }
461
462 #[test]
465 fn not_found_when_no_battery() {
466 let fs = FakeSysfs::new();
467 fs.add_entry("ACAD", "Mains\n");
468
469 let err = fs.reader().read().expect_err("should be NotFound");
470 assert!(
471 matches!(err, BatteryError::NotFound),
472 "expected NotFound, got {err}"
473 );
474 }
475
476 #[test]
479 fn time_to_empty_from_energy() {
480 let fs = FakeSysfs::new();
481 let d = fs.add_battery("BAT0", 50, "Discharging");
482 fs::write(d.join("energy_now"), "36000000\n").unwrap();
485 fs::write(d.join("power_now"), "18000000\n").unwrap();
486
487 let reading = fs.reader().read().expect("read ok");
488 assert_eq!(reading.time_to_empty, Some(Duration::from_secs(7200)));
489 }
490
491 #[test]
494 fn time_to_empty_from_charge() {
495 let fs = FakeSysfs::new();
496 let d = fs.add_battery("BAT0", 50, "Discharging");
497 fs::write(d.join("charge_now"), "3600\n").unwrap();
500 fs::write(d.join("current_now"), "1800\n").unwrap();
501
502 let reading = fs.reader().read().expect("read ok");
503 assert_eq!(reading.time_to_empty, Some(Duration::from_secs(7200)));
504 }
505
506 #[test]
509 fn time_to_empty_none_when_files_missing() {
510 let fs = FakeSysfs::new();
511 fs.add_battery("BAT0", 50, "Discharging");
512 let reading = fs.reader().read().expect("read ok");
515 assert!(
516 reading.time_to_empty.is_none(),
517 "expected None for time_to_empty"
518 );
519 }
520
521 #[test]
524 fn time_to_empty_none_when_charging() {
525 let fs = FakeSysfs::new();
526 let d = fs.add_battery("BAT0", 80, "Charging");
527 fs::write(d.join("energy_now"), "36000000\n").unwrap();
528 fs::write(d.join("power_now"), "18000000\n").unwrap();
529
530 let reading = fs.reader().read().expect("read ok");
531 assert!(
532 reading.time_to_empty.is_none(),
533 "time_to_empty should be None while charging"
534 );
535 }
536
537 #[test]
540 fn time_to_empty_none_when_power_zero() {
541 let fs = FakeSysfs::new();
542 let d = fs.add_battery("BAT0", 50, "Discharging");
543 fs::write(d.join("energy_now"), "36000000\n").unwrap();
544 fs::write(d.join("power_now"), "0\n").unwrap();
545
546 let reading = fs.reader().read().expect("read ok");
547 assert!(
548 reading.time_to_empty.is_none(),
549 "time_to_empty should be None when power_now is 0"
550 );
551 }
552
553 #[test]
556 fn unsupported_reader_errors() {
557 let reader = UnsupportedReader {
558 platform: "testplatform",
559 };
560 let err = reader.read().expect_err("should be Unsupported");
561 assert!(
562 matches!(err, BatteryError::Unsupported("testplatform")),
563 "expected Unsupported, got {err}"
564 );
565 }
566
567 #[test]
570 fn mock_reader_ok() {
571 let mock = MockBatteryReader {
572 reading: Ok(BatteryReading {
573 percent: 42,
574 status: BatteryStatus::Discharging,
575 time_to_empty: Some(Duration::from_secs(3600)),
576 }),
577 };
578 let r = mock.read().expect("mock ok");
579 assert_eq!(r.percent, 42);
580 assert_eq!(r.time_to_empty, Some(Duration::from_secs(3600)));
581 }
582
583 #[test]
584 fn mock_reader_not_found() {
585 let mock = MockBatteryReader {
586 reading: Err(BatteryError::NotFound),
587 };
588 assert!(matches!(mock.read(), Err(BatteryError::NotFound)));
589 }
590}