umsh_bsp_nrf52840/
rescue.rs

1//! USB-CDC DFU rescue paths.
2//!
3//! Two low-level mechanisms for getting the device into DFU mode
4//! independent of the CLI session:
5//!
6//! - [`TouchlessResetWatcher`] — watches CDC control requests for the
7//!   1200-baud touchless reset (host opens port at 1200 baud and then
8//!   drops DTR). This is how `flasher.meshcore.co.uk` and
9//!   `adafruit-nrfutil --touch 1200` trigger DFU. The Adafruit nRF52
10//!   bootloader does **not** implement this; firmware is responsible.
11//!
12//! - [`EscapeWatcher`] — observes the inbound byte stream and fires
13//!   when the magic sequence `Ctrl-C Ctrl-C Ctrl-C dfu\r` appears.
14//!   Used when the CLI parser is wedged (panicked task, deadlocked
15//!   channel, mis-parsed mode) so a human at a terminal can still
16//!   force DFU.
17//!
18//! Both mechanisms run *below* the CLI parser so a hung or
19//! mis-configured CLI can't block them. The escape watcher
20//! deliberately observes without consuming, so the CLI parser still
21//! sees all bytes — Ctrl-C continues to mean "abort" to the CLI even
22//! while the rescue prefix accumulates.
23
24/// What a watcher decided to do as a result of an input event.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum RescueAction {
27    /// No action this event.
28    None,
29    /// Caller should immediately enter DFU mode. The watcher does not
30    /// pick a mode — the caller decides between
31    /// [`bsp::enter_dfu_uf2()`] (GPREGRET=0x57, exposes CDC + UF2 mass
32    /// storage; what the MeshCore / Adafruit web flashers expect and
33    /// what `adafruit-nrfutil --touch 1200` triggers in the Adafruit
34    /// Arduino reference) and [`bsp::enter_dfu_serial()`]
35    /// (GPREGRET=0x4e, CDC-only; for `adafruit-nrfutil` / `nrfutil`
36    /// users who explicitly want the slimmer interface).
37    TriggerDfu,
38}
39
40/// Watcher for the 1200-baud touchless reset.
41///
42/// Track CDC `SET_LINE_CODING` (baud rate) and
43/// `SET_CONTROL_LINE_STATE` (DTR / RTS) events. When the host opens
44/// the port at 1200 baud and then drops DTR, return
45/// [`RescueAction::TriggerDfu`] so the caller can put the
46/// device into serial DFU mode.
47///
48/// The watcher self-suppresses after firing — once
49/// [`TouchlessResetWatcher::fired`] is true, subsequent events return
50/// `None` until [`TouchlessResetWatcher::reset`] is called. In normal
51/// operation the BSP's `enter_dfu_serial()` diverges so the
52/// suppression is moot; it exists for defensive symmetry and
53/// testability.
54#[derive(Debug)]
55pub struct TouchlessResetWatcher {
56    baud: u32,
57    dtr: bool,
58    fired: bool,
59}
60
61impl Default for TouchlessResetWatcher {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl TouchlessResetWatcher {
68    /// Construct with the conventional power-on assumption: baud
69    /// 115200, DTR low (host has not yet opened the port).
70    pub const fn new() -> Self {
71        Self {
72            baud: 115_200,
73            dtr: false,
74            fired: false,
75        }
76    }
77
78    /// Notify of a CDC `SET_LINE_CODING` request.
79    pub fn on_line_coding(&mut self, baud: u32) -> RescueAction {
80        self.baud = baud;
81        RescueAction::None
82    }
83
84    /// Notify of a CDC `SET_CONTROL_LINE_STATE` request. Only `dtr`
85    /// is consulted; `rts` is accepted for API completeness but
86    /// ignored, since the Arduino-ecosystem 1200-baud convention is
87    /// DTR-driven.
88    pub fn on_control_line_state(&mut self, dtr: bool, _rts: bool) -> RescueAction {
89        let was_high = self.dtr;
90        self.dtr = dtr;
91        if was_high && !dtr && self.baud == 1_200 && !self.fired {
92            self.fired = true;
93            return RescueAction::TriggerDfu;
94        }
95        RescueAction::None
96    }
97
98    pub fn fired(&self) -> bool {
99        self.fired
100    }
101
102    pub fn reset(&mut self) {
103        self.fired = false;
104    }
105}
106
107/// Observes inbound CDC bytes for the rescue prefix
108/// `Ctrl-C Ctrl-C Ctrl-C dfu` followed by `\r` or `\n`. On match,
109/// returns [`RescueAction::TriggerDfu`] so the caller can put the
110/// device into serial DFU mode independent of the CLI session.
111///
112/// The watcher is *non-consuming*: callers should hand each received
113/// byte to [`EscapeWatcher::observe`] before passing it to the CLI
114/// parser, not instead of. This preserves Ctrl-C's "abort" semantics
115/// inside the CLI.
116#[derive(Debug)]
117pub struct EscapeWatcher {
118    state: EscapeState,
119    fired: bool,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum EscapeState {
124    Idle,
125    Ctrl1,
126    Ctrl2,
127    Armed,
128    GotD,
129    GotDf,
130    GotDfu,
131}
132
133const CTRL_C: u8 = 0x03;
134
135impl Default for EscapeWatcher {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl EscapeWatcher {
142    pub const fn new() -> Self {
143        Self {
144            state: EscapeState::Idle,
145            fired: false,
146        }
147    }
148
149    /// Feed one received byte. Returns `TriggerDfu` exactly once
150    /// on completion of the magic sequence; further bytes return `None`
151    /// until [`reset`](Self::reset) is called.
152    pub fn observe(&mut self, byte: u8) -> RescueAction {
153        if self.fired {
154            return RescueAction::None;
155        }
156
157        self.state = match (self.state, byte) {
158            // Building up the Ctrl-C prefix.
159            (EscapeState::Idle, CTRL_C) => EscapeState::Ctrl1,
160            (EscapeState::Ctrl1, CTRL_C) => EscapeState::Ctrl2,
161            (EscapeState::Ctrl2, CTRL_C) => EscapeState::Armed,
162
163            // Armed: matching "dfu".
164            (EscapeState::Armed, b'd') => EscapeState::GotD,
165            (EscapeState::GotD, b'f') => EscapeState::GotDf,
166            (EscapeState::GotDf, b'u') => EscapeState::GotDfu,
167
168            // Terminator after "dfu" — fire.
169            (EscapeState::GotDfu, b'\r') | (EscapeState::GotDfu, b'\n') => {
170                self.fired = true;
171                self.state = EscapeState::Idle;
172                return RescueAction::TriggerDfu;
173            }
174
175            // Anything else in a Ctrl-prefix or armed sub-state resets,
176            // but a Ctrl-C in those positions restarts the prefix
177            // (so e.g. four Ctrl-Cs still arms after the third).
178            (_, CTRL_C) => EscapeState::Ctrl1,
179            _ => EscapeState::Idle,
180        };
181        RescueAction::None
182    }
183
184    /// Convenience: observe every byte in a slice.
185    pub fn observe_slice(&mut self, bytes: &[u8]) -> RescueAction {
186        for &b in bytes {
187            if let RescueAction::TriggerDfu = self.observe(b) {
188                return RescueAction::TriggerDfu;
189            }
190        }
191        RescueAction::None
192    }
193
194    pub fn fired(&self) -> bool {
195        self.fired
196    }
197
198    pub fn reset(&mut self) {
199        self.state = EscapeState::Idle;
200        self.fired = false;
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn open(w: &mut TouchlessResetWatcher, baud: u32) {
209        w.on_line_coding(baud);
210        w.on_control_line_state(true, true);
211    }
212
213    fn close(w: &mut TouchlessResetWatcher) -> RescueAction {
214        w.on_control_line_state(false, false)
215    }
216
217    #[test]
218    fn default_does_not_fire() {
219        let w = TouchlessResetWatcher::new();
220        assert!(!w.fired());
221    }
222
223    #[test]
224    fn normal_open_close_does_not_fire() {
225        let mut w = TouchlessResetWatcher::new();
226        open(&mut w, 115_200);
227        assert_eq!(close(&mut w), RescueAction::None);
228        assert!(!w.fired());
229    }
230
231    #[test]
232    fn open_at_1200_then_close_fires() {
233        let mut w = TouchlessResetWatcher::new();
234        open(&mut w, 1_200);
235        assert_eq!(close(&mut w), RescueAction::TriggerDfu);
236        assert!(w.fired());
237    }
238
239    #[test]
240    fn change_to_1200_then_close_fires() {
241        // Open at 115200, then change baud to 1200, then close.
242        let mut w = TouchlessResetWatcher::new();
243        open(&mut w, 115_200);
244        w.on_line_coding(1_200);
245        assert_eq!(close(&mut w), RescueAction::TriggerDfu);
246    }
247
248    #[test]
249    fn change_away_from_1200_before_close_does_not_fire() {
250        // Open at 1200, change to 115200, close.
251        let mut w = TouchlessResetWatcher::new();
252        open(&mut w, 1_200);
253        w.on_line_coding(115_200);
254        assert_eq!(close(&mut w), RescueAction::None);
255    }
256
257    #[test]
258    fn dtr_drop_without_prior_assertion_does_not_fire() {
259        // Adversarial sequence: SET_LINE_CODING(1200) then immediate
260        // DTR=false without a prior DTR=true. There was no falling
261        // edge from "open" to "close" — host never opened the port.
262        let mut w = TouchlessResetWatcher::new();
263        w.on_line_coding(1_200);
264        assert_eq!(close(&mut w), RescueAction::None);
265        assert!(!w.fired());
266    }
267
268    #[test]
269    fn fires_only_once_until_reset() {
270        let mut w = TouchlessResetWatcher::new();
271        open(&mut w, 1_200);
272        assert_eq!(close(&mut w), RescueAction::TriggerDfu);
273        // Reopen at 1200 and reclose; should NOT fire again until reset.
274        open(&mut w, 1_200);
275        assert_eq!(close(&mut w), RescueAction::None);
276
277        w.reset();
278        open(&mut w, 1_200);
279        assert_eq!(close(&mut w), RescueAction::TriggerDfu);
280    }
281
282    #[test]
283    fn dtr_high_to_high_is_not_a_close() {
284        // Host setting DTR=true multiple times without a drop in between
285        // must not be misinterpreted.
286        let mut w = TouchlessResetWatcher::new();
287        w.on_line_coding(1_200);
288        w.on_control_line_state(true, false);
289        assert_eq!(w.on_control_line_state(true, true), RescueAction::None);
290        assert!(!w.fired());
291    }
292
293    #[test]
294    fn rts_changes_are_ignored() {
295        // Toggling RTS while DTR stays high should not affect anything.
296        let mut w = TouchlessResetWatcher::new();
297        w.on_line_coding(1_200);
298        w.on_control_line_state(true, false);
299        assert_eq!(w.on_control_line_state(true, true), RescueAction::None);
300        assert_eq!(w.on_control_line_state(true, false), RescueAction::None);
301    }
302
303    // ---------- EscapeWatcher tests ----------
304
305    const MAGIC: &[u8] = b"\x03\x03\x03dfu\r";
306
307    #[test]
308    fn escape_default_does_not_fire() {
309        let w = EscapeWatcher::new();
310        assert!(!w.fired());
311    }
312
313    #[test]
314    fn escape_magic_sequence_fires() {
315        let mut w = EscapeWatcher::new();
316        assert_eq!(w.observe_slice(MAGIC), RescueAction::TriggerDfu);
317        assert!(w.fired());
318    }
319
320    #[test]
321    fn escape_magic_sequence_with_lf_fires() {
322        let mut w = EscapeWatcher::new();
323        assert_eq!(
324            w.observe_slice(b"\x03\x03\x03dfu\n"),
325            RescueAction::TriggerDfu
326        );
327    }
328
329    #[test]
330    fn escape_random_bytes_do_not_fire() {
331        let mut w = EscapeWatcher::new();
332        assert_eq!(w.observe_slice(b"hello world\r\n"), RescueAction::None);
333        assert!(!w.fired());
334    }
335
336    #[test]
337    fn escape_two_ctrl_c_then_other_resets() {
338        let mut w = EscapeWatcher::new();
339        w.observe_slice(b"\x03\x03x");
340        // Now the full magic sequence should still work afterwards.
341        assert_eq!(w.observe_slice(MAGIC), RescueAction::TriggerDfu);
342    }
343
344    #[test]
345    fn escape_three_ctrl_c_then_wrong_command_resets() {
346        let mut w = EscapeWatcher::new();
347        assert_eq!(w.observe_slice(b"\x03\x03\x03nope\r"), RescueAction::None);
348        // Re-attempt with the right sequence should still work.
349        assert_eq!(w.observe_slice(MAGIC), RescueAction::TriggerDfu);
350    }
351
352    #[test]
353    fn escape_is_case_sensitive() {
354        // Uppercase DFU should NOT trigger.
355        let mut w = EscapeWatcher::new();
356        assert_eq!(w.observe_slice(b"\x03\x03\x03DFU\r"), RescueAction::None);
357    }
358
359    #[test]
360    fn escape_extra_ctrl_c_after_arm_restarts_prefix() {
361        // Four Ctrl-Cs in a row: the fourth lands us back in Ctrl1
362        // (so the prefix is partially re-built rather than completely
363        // lost), then we still need two more Ctrl-Cs to re-arm.
364        let mut w = EscapeWatcher::new();
365        w.observe_slice(b"\x03\x03\x03\x03"); // 4 Ctrl-Cs
366        // Currently in Ctrl1 (4th Ctrl-C reset from Armed back to Ctrl1).
367        // Need two more Ctrl-Cs to re-arm.
368        assert_eq!(w.observe_slice(b"\x03\x03dfu\r"), RescueAction::TriggerDfu);
369    }
370
371    #[test]
372    fn escape_magic_in_middle_of_stream_fires() {
373        let mut w = EscapeWatcher::new();
374        let stream = b"some other text\x03\x03\x03dfu\r more stuff";
375        assert_eq!(w.observe_slice(stream), RescueAction::TriggerDfu);
376    }
377
378    #[test]
379    fn escape_fires_only_once_until_reset() {
380        let mut w = EscapeWatcher::new();
381        assert_eq!(w.observe_slice(MAGIC), RescueAction::TriggerDfu);
382        assert_eq!(w.observe_slice(MAGIC), RescueAction::None);
383
384        w.reset();
385        assert_eq!(w.observe_slice(MAGIC), RescueAction::TriggerDfu);
386    }
387
388    #[test]
389    fn escape_observes_individual_bytes() {
390        let mut w = EscapeWatcher::new();
391        let mut fired = false;
392        for &b in MAGIC {
393            if let RescueAction::TriggerDfu = w.observe(b) {
394                fired = true;
395            }
396        }
397        assert!(fired);
398    }
399}