Skip to main content

krypt_core/
battery.rs

1//! Battery status reader.
2//!
3//! Provides a platform-abstracted API for reading battery state. On Linux,
4//! [`LinuxSysfsReader`] walks `/sys/class/power_supply/` for the first
5//! `Battery`-typed entry and reads `capacity`, `status`, `energy_now`,
6//! `power_now` (or `charge_now` / `current_now` as fallback).
7//!
8//! Non-Linux targets get [`UnsupportedReader`] which always returns
9//! [`BatteryError::Unsupported`]. [`default_reader`] picks the right
10//! implementation for the current platform.
11//!
12//! # Example
13//!
14//! ```no_run
15//! use krypt_core::battery::default_reader;
16//!
17//! let reader = default_reader();
18//! match reader.read() {
19//!     Ok(r) => println!("{}% ({})", r.percent, r.status),
20//!     Err(e) => eprintln!("battery error: {e}"),
21//! }
22//! ```
23
24use std::fmt;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28// ─── Error ───────────────────────────────────────────────────────────────────
29
30/// Errors that can occur while reading battery state.
31#[derive(Debug)]
32pub enum BatteryError {
33    /// No battery device found on this system.
34    NotFound,
35    /// An I/O error occurred while reading sysfs files.
36    Io(std::io::Error),
37    /// A sysfs file contained an unexpected value.
38    Parse(String),
39    /// Battery reading is not implemented on this platform.
40    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// ─── Status enum ─────────────────────────────────────────────────────────────
72
73/// Battery charge/discharge status.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum BatteryStatus {
76    /// Battery is charging.
77    Charging,
78    /// Battery is discharging.
79    Discharging,
80    /// Battery is full.
81    Full,
82    /// Battery is not charging (plugged in but not charging, some firmware).
83    NotCharging,
84    /// Status could not be determined.
85    Unknown,
86}
87
88impl BatteryStatus {
89    /// Parse a sysfs status string into a [`BatteryStatus`].
90    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    /// Return the original sysfs casing string.
101    ///
102    /// Used in CSV log output to preserve the exact format of the bash scripts.
103    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        // Lowercase form for human-readable output.
117        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// ─── Reading ─────────────────────────────────────────────────────────────────
128
129/// A single battery reading.
130#[derive(Debug, Clone)]
131pub struct BatteryReading {
132    /// Charge percentage (0..=100).
133    pub percent: u8,
134    /// Current charge/discharge status.
135    pub status: BatteryStatus,
136    /// Estimated time to empty.
137    ///
138    /// `None` when charging, full, or the sysfs files needed to compute it
139    /// are absent or report zero power draw.
140    pub time_to_empty: Option<Duration>,
141}
142
143// ─── Trait ───────────────────────────────────────────────────────────────────
144
145/// Abstraction over battery hardware.
146///
147/// Implement this trait to provide battery readings from a real or simulated
148/// source. Implementations must be `Send + Sync` so they can be shared across
149/// threads without restriction.
150pub trait BatteryReader: Send + Sync {
151    /// Read the current battery state.
152    fn read(&self) -> Result<BatteryReading, BatteryError>;
153}
154
155// ─── Linux sysfs reader ───────────────────────────────────────────────────────
156
157/// Reads battery state from the Linux sysfs power-supply interface.
158///
159/// Scans `/sys/class/power_supply/` (or an override root for tests) for the
160/// first entry whose `type` file contains `Battery`. Reads `capacity`,
161/// `status`, and optionally `energy_now` + `power_now` (or `charge_now` +
162/// `current_now`) to compute [`BatteryReading::time_to_empty`].
163pub struct LinuxSysfsReader {
164    root: PathBuf,
165}
166
167impl LinuxSysfsReader {
168    /// Create a reader backed by the real sysfs tree.
169    pub fn new() -> Self {
170        Self {
171            root: PathBuf::from("/sys/class/power_supply"),
172        }
173    }
174
175    /// Create a reader backed by a custom root directory.
176    ///
177    /// Used in tests to point at a fake sysfs tree built with `tempfile`.
178    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        // Sort for deterministic BAT0-before-BAT1 ordering.
203        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        // Try energy_now (µWh) / power_now (µW) first.
215        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        // Fallback: charge_now (µAh) / current_now (µA).
225        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        // capacity: 0..=100
249        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        // status
257        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        // time_to_empty: only when discharging
262        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
276// ─── Unsupported stub ─────────────────────────────────────────────────────────
277
278/// Battery reader for platforms where sysfs is unavailable.
279///
280/// Always returns [`BatteryError::Unsupported`].
281pub struct UnsupportedReader {
282    /// Name of the platform (e.g. `"macos"`, `"windows"`).
283    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
292// ─── Default reader factory ───────────────────────────────────────────────────
293
294/// Return the best [`BatteryReader`] for the current platform.
295///
296/// - Linux → [`LinuxSysfsReader`] backed by `/sys/class/power_supply`.
297/// - All other targets → [`UnsupportedReader`].
298pub 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
315// ─── Mock reader (test helper) ────────────────────────────────────────────────
316
317/// A mock [`BatteryReader`] that returns a fixed result.
318///
319/// Useful in unit tests where a real sysfs tree is unavailable.
320pub struct MockBatteryReader {
321    /// The result to return from [`BatteryReader::read`].
322    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            // io::Error isn't Clone; rebuild a fresh one preserving kind + message
333            // so the mock reports the same variant the caller configured.
334            Err(BatteryError::Io(e)) => Err(BatteryError::Io(std::io::Error::new(
335                e.kind(),
336                e.to_string(),
337            ))),
338        }
339    }
340}
341
342// ─── Tests ───────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::fs;
348    use tempfile::TempDir;
349
350    // ── Helper to build a fake sysfs tree ────────────────────────────────────
351
352    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        /// Add a power_supply entry with a given name and `type` value.
364        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        /// Add a Battery entry with capacity + status.
372        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    // ── Status round-trip ─────────────────────────────────────────────────────
385
386    #[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    // ── Basic read: BAT0 ─────────────────────────────────────────────────────
424
425    #[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    // ── BAT0 vs BAT1 discovery: skips non-Battery type ───────────────────────
436
437    #[test]
438    fn skips_non_battery_entry() {
439        let fs = FakeSysfs::new();
440        // ACAD is an AC adapter — type is "Mains", not "Battery".
441        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    // ── BAT0 before BAT1 (sort order) ────────────────────────────────────────
450
451    #[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        // BAT0 < BAT1 lexicographically → should be selected.
459        assert_eq!(reading.percent, 90);
460    }
461
462    // ── NotFound when no Battery entries exist ────────────────────────────────
463
464    #[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    // ── time_to_empty math: energy_now / power_now ────────────────────────────
477
478    #[test]
479    fn time_to_empty_from_energy() {
480        let fs = FakeSysfs::new();
481        let d = fs.add_battery("BAT0", 50, "Discharging");
482        // energy_now = 36_000_000 µWh, power_now = 18_000_000 µW
483        // → secs = (36_000_000 * 3600) / 18_000_000 = 7200 s = 2 h
484        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    // ── time_to_empty: fallback to charge_now / current_now ──────────────────
492
493    #[test]
494    fn time_to_empty_from_charge() {
495        let fs = FakeSysfs::new();
496        let d = fs.add_battery("BAT0", 50, "Discharging");
497        // No energy_now; use charge_now = 3600 µAh, current_now = 1800 µA
498        // → secs = (3600 * 3600) / 1800 = 7200 s
499        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    // ── time_to_empty: None when neither energy_now nor charge_now ───────────
507
508    #[test]
509    fn time_to_empty_none_when_files_missing() {
510        let fs = FakeSysfs::new();
511        fs.add_battery("BAT0", 50, "Discharging");
512        // No energy_now, no charge_now files.
513
514        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    // ── time_to_empty: None when not discharging ──────────────────────────────
522
523    #[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    // ── time_to_empty: None when power_now is zero ────────────────────────────
538
539    #[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    // ── UnsupportedReader always errors ───────────────────────────────────────
554
555    #[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    // ── MockBatteryReader returns fixed reading ────────────────────────────────
568
569    #[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}