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}