Skip to main content

dmx_interface/hardware/
button.rs

1//! User button (GPIO5, active-low): power-up factory reset and runtime
2//! click actions.
3//!
4//! One pin, two phases. [`boot_factory_reset`] runs first, awaited straight
5//! from `main` before the network task is spawned (but only after a
6//! non-software reset — a reboot we triggered ourselves must never re-enter
7//! the hold while the button is still down):
8//!
9//! | Phase | Trigger | Behavior |
10//! | ----- | ------- | --------- |
11//! | Boot hold | held at power-up | [`LedStatus::Resetting`] for [`RESET_HOLD`], then [`storage::clear`] → solid LED → wait for release → reboot; releasing earlier aborts with [`LedStatus::Warning`] |
12//! | Click actions | presses after boot | classified into [`ButtonEvent`]s, dispatched from `Config::button_actions` |
13//!
14//! Event discriminants are the `button_actions` map keys (web: `ButtonEvent`):
15//! 0 single click, 1 double click, 2 multiple (3) clicks, 3 long hold. The
16//! action is read from NVS on every event, so the web UI can rebind clicks
17//! without a reboot.
18
19use embassy_time::{Duration, Instant, Timer};
20use esp_hal::{
21    gpio::{Input, InputConfig, Pull},
22    peripherals::GPIO5,
23    rtc_cntl::SocResetReason,
24    system::reset_reason,
25};
26
27use crate::{
28    config::ButtonAction,
29    hardware::led::{self, LedStatus},
30    storage,
31};
32
33/// Poll interval of the boot hold check and the click classifier.
34const POLL: Duration = Duration::from_millis(10);
35
36/// A raw level must hold this long before the stable level follows it.
37const DEBOUNCE: Duration = Duration::from_millis(30);
38
39/// Power-up hold before the factory reset fires.
40const RESET_HOLD: Duration = Duration::from_secs(3);
41
42/// Maximum gap between two presses of one click sequence.
43const CLICK_GAP: Duration = Duration::from_millis(600);
44
45/// Press duration that fires [`ButtonEvent::LongHold`].
46const LONG_HOLD: Duration = Duration::from_millis(1500);
47
48/// Progress-logging cadence while the boot hold runs.
49const HOLD_LOG_EVERY: Duration = Duration::from_millis(500);
50
51/// Delay before a button-triggered reboot so the last log line drains.
52const REBOOT_DELAY: Duration = Duration::from_millis(100);
53
54/// Brightness ladder of [`ButtonAction::ToggleLed`] (legacy behavior).
55const BRIGHTNESS_LEVELS: [u8; 5] = [0, 1, 20, 100, 255];
56
57/// Button event classified at runtime.
58///
59/// The discriminant is the key into `Config::button_actions` (web:
60/// `ButtonEvent`).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ButtonEvent {
63    /// One press and release (`button_actions` key 0).
64    SingleClick = 0,
65    /// Two presses within [`CLICK_GAP`] (key 1).
66    DoubleClick = 1,
67    /// Three presses within [`CLICK_GAP`] (key 2).
68    MultipleClick = 2,
69    /// Held for at least [`LONG_HOLD`] (key 3).
70    LongHold = 3,
71}
72
73impl From<ButtonEvent> for u8 {
74    /// Event → `button_actions` map key.
75    fn from(event: ButtonEvent) -> Self {
76        match event {
77            ButtonEvent::SingleClick => 0,
78            ButtonEvent::DoubleClick => 1,
79            ButtonEvent::MultipleClick => 2,
80            ButtonEvent::LongHold => 3,
81        }
82    }
83}
84
85/// Configures `pin` as the user-button input (pull-up, active low).
86pub fn input(pin: GPIO5<'static>) -> Input<'static> {
87    Input::new(pin, InputConfig::default().with_pull(Pull::Up))
88}
89
90/// Power-up factory-reset hold (README: hold while applying power).
91///
92/// Returns immediately after a software-triggered boot (WS `reset`,
93/// [`ButtonAction::Reboot`], panic recovery) or when the button is not
94/// held. Otherwise the LED shows [`LedStatus::Resetting`] until
95/// [`RESET_HOLD`] elapses, the config is cleared, the LED turns solid
96/// ([`LedStatus::Ok`]) and the function waits for the release before
97/// rebooting. Releasing before the deadline aborts with
98/// [`LedStatus::Warning`] and boot continues.
99///
100/// Call after `storage::init` and the LED task are running. On a
101/// successful reset it never returns; if NVS cannot be cleared the reset is
102/// aborted (no reboot into a factory reset that did not happen).
103pub async fn boot_factory_reset(button: &Input<'_>) {
104    let reason = reset_reason();
105    if matches!(
106        reason,
107        Some(SocResetReason::CoreSw | SocResetReason::Cpu0Sw)
108    ) {
109        log::debug!("button: software-triggered boot, hold check skipped");
110        return;
111    }
112    log::debug!("button: boot reset reason {reason:?}");
113    if !button.is_low() {
114        log::debug!("button: not held at power-up");
115        return;
116    }
117    Timer::after(DEBOUNCE).await;
118    if !button.is_low() {
119        log::debug!("button: bounce at power-up, continuing boot");
120        return;
121    }
122
123    log::warn!(
124        "button: held at power-up — factory reset in {} ms",
125        RESET_HOLD.as_millis()
126    );
127    led::set(LedStatus::Resetting);
128    let held_since = Instant::now();
129    let mut last_log = Instant::now();
130    let mut debouncer = Debouncer::new(true);
131    loop {
132        let pressed = debouncer.update(button.is_low(), Instant::now());
133        if !pressed {
134            log::info!(
135                "button: released after {} ms — factory reset aborted",
136                held_since.elapsed().as_millis()
137            );
138            led::set(LedStatus::Warning);
139            return;
140        }
141        let held = held_since.elapsed();
142        if held >= RESET_HOLD {
143            break;
144        }
145        if last_log.elapsed() >= HOLD_LOG_EVERY {
146            last_log = Instant::now();
147            log::info!("button: still held ({} ms)", held.as_millis());
148        }
149        Timer::after(POLL).await;
150    }
151
152    if !storage::clear() {
153        log::error!("button: factory reset failed to clear NVS — not rebooting");
154        led::set(LedStatus::Error);
155        wait_release(button).await;
156        return;
157    }
158    log::warn!("button: config cleared — release the button to reboot");
159    led::set(LedStatus::Ok);
160    wait_release(button).await;
161    Timer::after(REBOOT_DELAY).await;
162    esp_hal::system::software_reset();
163}
164
165/// Runs the configured action for `event`, read fresh from NVS.
166async fn dispatch(event: ButtonEvent) {
167    let mut config = storage::load();
168    let Some(action) = config.button_actions.get(&u8::from(event)).copied() else {
169        log::info!("button: {event:?} — no action configured");
170        return;
171    };
172    match action {
173        ButtonAction::None => {
174            log::debug!("button: {event:?} → none");
175        }
176        ButtonAction::ToggleLed => {
177            config.led_brightness = next_brightness(config.led_brightness);
178            if storage::save(&config) {
179                config.apply();
180                log::info!(
181                    "button: {event:?} → LED brightness {}",
182                    config.led_brightness
183                );
184            } else {
185                log::error!("button: {event:?} → ToggleLed, config save failed");
186            }
187        }
188        ButtonAction::Reboot => {
189            log::info!("button: {event:?} → reboot");
190            Timer::after(REBOOT_DELAY).await;
191            esp_hal::system::software_reset();
192        }
193    }
194}
195
196/// Next [`BRIGHTNESS_LEVELS`] step above `current`, wrapping to the
197/// dimmest level after the brightest one.
198fn next_brightness(current: u8) -> u8 {
199    BRIGHTNESS_LEVELS
200        .iter()
201        .copied()
202        .find(|&level| level > current)
203        .unwrap_or(0)
204}
205
206/// Classifies presses and dispatches the configured actions.
207///
208/// Owns the button pin for the rest of the firmware; the boot phase runs
209/// earlier, in [`boot_factory_reset`], before this task is spawned.
210#[embassy_executor::task]
211pub async fn task(button: Input<'static>) -> ! {
212    let mut classifier = Classifier::new(button.is_low());
213    log::info!("button: click actions armed");
214    loop {
215        Timer::after(POLL).await;
216        if let Some(event) = classifier.update(button.is_low(), Instant::now()) {
217            dispatch(event).await;
218        }
219    }
220}
221
222/// Phase of the click sequence.
223#[derive(Clone, Copy)]
224enum State {
225    /// No press in flight.
226    Idle,
227    /// Debounced press active.
228    Holding {
229        /// Press start (debounced).
230        since: Instant,
231        /// Completed presses before this hold.
232        clicks: u8,
233        /// [`ButtonEvent::LongHold`] already dispatched for this hold.
234        long_fired: bool,
235    },
236    /// Released after `clicks` presses; the next press must arrive within
237    /// [`CLICK_GAP`] or the sequence is reported.
238    Gap {
239        /// Completed presses of the sequence.
240        clicks: u8,
241        /// Release the click window runs from.
242        released_at: Instant,
243    },
244}
245
246/// Debounced level filter: a raw change must hold for [`DEBOUNCE`] before
247/// the stable level follows it.
248struct Debouncer {
249    /// Raw level of the last poll.
250    raw: bool,
251    /// When `raw` last changed.
252    raw_since: Instant,
253    /// Debounced level.
254    stable: bool,
255}
256
257impl Debouncer {
258    /// Seeds both levels with `stable`, the known current level.
259    fn new(stable: bool) -> Self {
260        Self {
261            raw: stable,
262            raw_since: Instant::now(),
263            stable,
264        }
265    }
266
267    /// Feeds one poll of the raw level and returns the stable level.
268    fn update(&mut self, raw: bool, now: Instant) -> bool {
269        if raw != self.raw {
270            self.raw = raw;
271            self.raw_since = now;
272        } else if raw != self.stable && now.saturating_duration_since(self.raw_since) >= DEBOUNCE {
273            self.stable = raw;
274        }
275        self.stable
276    }
277}
278
279/// Debounced press/hold/click state machine, polled at [`POLL`].
280struct Classifier {
281    /// Level filter feeding the state machine.
282    debouncer: Debouncer,
283    /// Current click-sequence phase.
284    state: State,
285}
286
287impl Classifier {
288    /// Creates a classifier synced to the current level.
289    ///
290    /// A button already held at spawn is treated as an in-progress hold
291    /// with [`ButtonEvent::LongHold`] suppressed, so a reboot triggered by
292    /// a held button (e.g. `LongHold → Reboot`) waits for the release
293    /// before it accepts a new event instead of looping.
294    fn new(pressed: bool) -> Self {
295        Self {
296            debouncer: Debouncer::new(pressed),
297            state: if pressed {
298                State::Holding {
299                    since: Instant::now(),
300                    clicks: 0,
301                    long_fired: true,
302                }
303            } else {
304                State::Idle
305            },
306        }
307    }
308
309    /// Feeds one poll of the raw level (`pressed` = active-low reading)
310    /// and returns the event that completed, if any.
311    fn update(&mut self, pressed: bool, now: Instant) -> Option<ButtonEvent> {
312        let pressed = self.debouncer.update(pressed, now);
313        let (state, event) = match self.state {
314            State::Idle => {
315                if pressed {
316                    (
317                        State::Holding {
318                            since: now,
319                            clicks: 0,
320                            long_fired: false,
321                        },
322                        None,
323                    )
324                } else {
325                    (State::Idle, None)
326                }
327            }
328            State::Holding {
329                since,
330                clicks,
331                long_fired,
332            } => {
333                if !pressed {
334                    let clicks = clicks.saturating_add(1);
335                    if long_fired {
336                        (State::Idle, None)
337                    } else if clicks >= 3 {
338                        (State::Idle, Some(ButtonEvent::MultipleClick))
339                    } else {
340                        (
341                            State::Gap {
342                                clicks,
343                                released_at: now,
344                            },
345                            None,
346                        )
347                    }
348                } else if !long_fired && since.elapsed() >= LONG_HOLD {
349                    (
350                        State::Holding {
351                            since,
352                            clicks,
353                            long_fired: true,
354                        },
355                        Some(ButtonEvent::LongHold),
356                    )
357                } else {
358                    (
359                        State::Holding {
360                            since,
361                            clicks,
362                            long_fired,
363                        },
364                        None,
365                    )
366                }
367            }
368            State::Gap {
369                clicks,
370                released_at,
371            } => {
372                if released_at.elapsed() >= CLICK_GAP {
373                    let event = if clicks <= 1 {
374                        ButtonEvent::SingleClick
375                    } else {
376                        ButtonEvent::DoubleClick
377                    };
378                    (State::Idle, Some(event))
379                } else if pressed {
380                    (
381                        State::Holding {
382                            since: now,
383                            clicks,
384                            long_fired: false,
385                        },
386                        None,
387                    )
388                } else {
389                    (
390                        State::Gap {
391                            clicks,
392                            released_at,
393                        },
394                        None,
395                    )
396                }
397            }
398        };
399        self.state = state;
400        event
401    }
402}
403
404/// Polls until the button reads released (debounced).
405async fn wait_release(button: &Input<'_>) {
406    let mut debouncer = Debouncer::new(true);
407    while debouncer.update(button.is_low(), Instant::now()) {
408        Timer::after(POLL).await;
409    }
410}