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}