umsh_ux_tracker/
power.rs

1//! Power intents and supporting state.
2//!
3//! Defines the [`PowerIntent`] enum that multiple sources (button task,
4//! CLI command dispatcher, low-battery monitor, USB-CDC 1200-baud touch
5//! handler) submit via an embassy channel to the single `power_task`.
6//!
7//! This module deliberately stays free of embassy / hardware details so
8//! the intent vocabulary and the low-battery shutdown logic can be
9//! exercised without a runtime. The dispatch loop that maps intents to
10//! `bsp::enter_*` calls lives next to the runtime wiring.
11
12/// Things the firmware can decide to do that take the device out of its
13/// normal operating state. All variants are terminal — the calling task
14/// must assume the device will be reset or powered off shortly after
15/// the intent is delivered.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PowerIntent {
18    /// Enter nRF52840 System OFF; wake on button press.
19    PowerOff,
20    /// Reset into the Adafruit bootloader's UF2 mass-storage DFU mode
21    /// (`GPREGRET = 0x57`).
22    EnterDfuUf2,
23    /// Reset into the bootloader's serial / CDC DFU mode
24    /// (`GPREGRET = 0x4e`). Required by the WebSerial-based MeshCore
25    /// flasher and by `adafruit-nrfutil --touch 1200`.
26    EnterDfuSerial,
27    /// Plain warm reset back into the running firmware.
28    Reboot,
29}
30
31/// Why a [`PowerIntent`] was submitted. Useful for logging and
32/// post-mortem reporting via the persisted panic / event log.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PowerIntentSource {
35    /// User long-pressed the button.
36    ButtonLongPress,
37    /// User typed a CLI command.
38    CliCommand,
39    /// Low-battery monitor decided the cell needs to be protected.
40    LowBattery,
41    /// USB host did the 1200-baud DTR-drop dance.
42    UsbCdc1200BaudTouch,
43    /// USB-CDC rescue escape sequence.
44    UsbCdcRescueEscape,
45}
46
47/// Tracks consecutive low-battery samples and emits a [`PowerIntent::PowerOff`]
48/// once the streak crosses the configured threshold while the device is
49/// not USB-powered.
50///
51/// Matches the Meshtastic behavior documented in
52/// `docs/hardware/t1000e-hardware.md`: the T1000-E has no confirmed hardware
53/// undervoltage cutoff, so firmware must protect the Li-ion cell by
54/// shutting down before the voltage falls too far below the OCV table's
55/// 3.1 V floor.
56#[derive(Debug)]
57pub struct LowBatteryDetector {
58    threshold_mv: u16,
59    streak_required: u8,
60    streak: u8,
61    fired: bool,
62}
63
64impl LowBatteryDetector {
65    /// Create a detector. `threshold_mv` is the floor (samples strictly
66    /// below this count toward the streak); `streak_required` is the
67    /// number of consecutive low samples while not USB-powered needed
68    /// to fire.
69    pub fn new(threshold_mv: u16, streak_required: u8) -> Self {
70        debug_assert!(streak_required > 0);
71        Self {
72            threshold_mv,
73            streak_required,
74            streak: 0,
75            fired: false,
76        }
77    }
78
79    /// Default per the T1000-E plan: 3.1 V floor, 10-sample streak.
80    pub fn t1000e_default() -> Self {
81        Self::new(3_100, 10)
82    }
83
84    /// Feed one sample. Returns [`PowerIntent::PowerOff`] the first
85    /// time the streak crosses the threshold; subsequent calls return
86    /// `None` until [`reset`](Self::reset) is called. This lets the
87    /// power task act on a single fire and ignore further samples
88    /// while it tears down.
89    pub fn observe(&mut self, sample_mv: u16, usb_powered: bool) -> Option<PowerIntent> {
90        if self.fired {
91            return None;
92        }
93
94        // USB powering the board: streak is invalid (battery reading
95        // may be conditioned by charge current); reset and report
96        // nothing.
97        if usb_powered {
98            self.streak = 0;
99            return None;
100        }
101
102        if sample_mv < self.threshold_mv {
103            self.streak = self.streak.saturating_add(1);
104            if self.streak >= self.streak_required {
105                self.fired = true;
106                return Some(PowerIntent::PowerOff);
107            }
108        } else {
109            self.streak = 0;
110        }
111
112        None
113    }
114
115    /// Number of consecutive low-while-on-battery samples seen so far.
116    pub fn streak(&self) -> u8 {
117        self.streak
118    }
119
120    /// True once the detector has fired and is suppressing further events.
121    pub fn fired(&self) -> bool {
122        self.fired
123    }
124
125    /// Re-arm the detector (clears the fired flag and the streak).
126    pub fn reset(&mut self) {
127        self.streak = 0;
128        self.fired = false;
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn streak_fires_at_threshold() {
138        let mut d = LowBatteryDetector::new(3_100, 3);
139        assert!(d.observe(3_000, false).is_none());
140        assert!(d.observe(3_000, false).is_none());
141        assert_eq!(d.observe(3_000, false), Some(PowerIntent::PowerOff));
142    }
143
144    #[test]
145    fn good_sample_resets_streak() {
146        let mut d = LowBatteryDetector::new(3_100, 3);
147        d.observe(3_000, false);
148        d.observe(3_000, false);
149        // Single good sample resets.
150        assert!(d.observe(3_500, false).is_none());
151        assert_eq!(d.streak(), 0);
152        // Need a fresh streak of 3.
153        d.observe(3_000, false);
154        d.observe(3_000, false);
155        assert_eq!(d.observe(3_000, false), Some(PowerIntent::PowerOff));
156    }
157
158    #[test]
159    fn usb_powered_resets_streak() {
160        let mut d = LowBatteryDetector::new(3_100, 3);
161        d.observe(3_000, false);
162        d.observe(3_000, false);
163        // Plugging in resets even if voltage is still low.
164        assert!(d.observe(3_000, true).is_none());
165        assert_eq!(d.streak(), 0);
166        // Unplug + low: streak begins again, doesn't carry over.
167        d.observe(3_000, false);
168        d.observe(3_000, false);
169        assert_eq!(d.observe(3_000, false), Some(PowerIntent::PowerOff));
170    }
171
172    #[test]
173    fn boundary_at_threshold_is_not_low() {
174        let mut d = LowBatteryDetector::new(3_100, 1);
175        // 3100 mV exactly is the floor, not strictly below it.
176        assert!(d.observe(3_100, false).is_none());
177        // 3099 fires.
178        assert_eq!(d.observe(3_099, false), Some(PowerIntent::PowerOff));
179    }
180
181    #[test]
182    fn fires_only_once_until_reset() {
183        let mut d = LowBatteryDetector::new(3_100, 1);
184        assert_eq!(d.observe(3_000, false), Some(PowerIntent::PowerOff));
185        // Further samples ignored.
186        assert!(d.observe(3_000, false).is_none());
187        assert!(d.observe(3_500, false).is_none());
188        assert!(d.fired());
189
190        d.reset();
191        assert!(!d.fired());
192        assert_eq!(d.observe(3_000, false), Some(PowerIntent::PowerOff));
193    }
194
195    #[test]
196    fn t1000e_default_matches_plan() {
197        let d = LowBatteryDetector::t1000e_default();
198        // 3.1 V floor, 10 consecutive samples.
199        let mut d = d;
200        for _ in 0..9 {
201            assert!(d.observe(3_099, false).is_none());
202        }
203        assert_eq!(d.observe(3_099, false), Some(PowerIntent::PowerOff));
204    }
205}