From 64a48f2aea9163679085ec5d878a12692932882c Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Thu, 25 Jun 2026 23:56:08 +0200 Subject: [PATCH] fix(eink): capture button edges via GPIO interrupt to survive display refresh E-ink panel refreshes block the main loop for 500ms-3.6s (GxEPD2's display.display() waits on the BUSY pin), and MomentaryButton::check() only ever samples the live pin level once per loop iteration. A full press+release that lands entirely inside a refresh is never sampled, not just delayed - users on WioTracker L1 Eink were losing taps. Add an opt-in ISR path (BUTTON_USE_INTERRUPTS) that latches each edge with its own timestamp into a small ring buffer from attachInterrupt(), independent of what the main loop is doing. check() replays buffered edges through the existing transition/multi-click logic afterwards, so click-duration and multi-click-window math still uses the edge's actual capture time rather than "now". Scoped to wio-tracker-l1-eink only for now: it's the one board this was reported against and the only GxEPDDisplay-class variant that currently builds clean on main. OLED boards refresh in ~20-30ms so polling already catches every press; analog-button boards (rak4631/rak3401) can't use attachInterrupt(CHANGE) at all. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/MomentaryButton.cpp | 130 ++++++++++++++++---- src/helpers/ui/MomentaryButton.h | 35 ++++++ variants/wio-tracker-l1-eink/platformio.ini | 1 + 3 files changed, 140 insertions(+), 26 deletions(-) diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 9d01e5b0..659a81a0 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -2,6 +2,57 @@ #define MULTI_CLICK_WINDOW_MS 280 +#ifdef BUTTON_USE_INTERRUPTS +#define ISR_DEBOUNCE_MS 5 + +MomentaryButton* MomentaryButton::_isr_table[MomentaryButton::MAX_ISR_BUTTONS] = { nullptr }; + +#define DEFINE_ISR_TRAMPOLINE(n) \ + void MomentaryButton::isrTrampoline##n() { if (_isr_table[n]) _isr_table[n]->isrHandler(); } +DEFINE_ISR_TRAMPOLINE(0) +DEFINE_ISR_TRAMPOLINE(1) +DEFINE_ISR_TRAMPOLINE(2) +DEFINE_ISR_TRAMPOLINE(3) +DEFINE_ISR_TRAMPOLINE(4) +DEFINE_ISR_TRAMPOLINE(5) +DEFINE_ISR_TRAMPOLINE(6) +DEFINE_ISR_TRAMPOLINE(7) +#undef DEFINE_ISR_TRAMPOLINE + +// attachInterrupt() takes a plain void(*)() with no user-data slot on the +// Adafruit nRF52 and ESP32 Arduino cores, so each button needs its own +// trampoline to know which instance to dispatch to. +static void (*const ISR_TRAMPOLINES[MomentaryButton::MAX_ISR_BUTTONS])() = { + MomentaryButton::isrTrampoline0, MomentaryButton::isrTrampoline1, + MomentaryButton::isrTrampoline2, MomentaryButton::isrTrampoline3, + MomentaryButton::isrTrampoline4, MomentaryButton::isrTrampoline5, + MomentaryButton::isrTrampoline6, MomentaryButton::isrTrampoline7, +}; + +void MomentaryButton::pushEdge(uint8_t level, uint32_t at) { + uint8_t next = (_edge_head + 1) % EDGE_BUF_SIZE; + if (next == _edge_tail) return; // full: drop rather than clobber older, unprocessed edges + _edge_level[_edge_head] = level; + _edge_time[_edge_head] = at; + _edge_head = next; +} + +bool MomentaryButton::popEdge(uint8_t &level, uint32_t &at) { + if (_edge_tail == _edge_head) return false; + level = _edge_level[_edge_tail]; + at = _edge_time[_edge_tail]; + _edge_tail = (_edge_tail + 1) % EDGE_BUF_SIZE; + return true; +} + +void MomentaryButton::isrHandler() { + uint32_t now = millis(); + if (now - _last_isr_edge < ISR_DEBOUNCE_MS) return; // mechanical contact-bounce guard + _last_isr_edge = now; + pushEdge(digitalRead(_pin), now); +} +#endif + MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse, bool pulldownup, bool multiclick) { _pin = pin; _reverse = reverse; @@ -35,6 +86,16 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_t void MomentaryButton::begin() { if (_pin >= 0 && _threshold == 0) { pinMode(_pin, _pull ? (_reverse ? INPUT_PULLUP : INPUT_PULLDOWN) : INPUT); +#ifdef BUTTON_USE_INTERRUPTS + for (uint8_t i = 0; i < MAX_ISR_BUTTONS; i++) { + if (_isr_table[i] == nullptr) { + _isr_table[i] = this; + _isr_slot = i; + attachInterrupt(digitalPinToInterrupt(_pin), ISR_TRAMPOLINES[i], CHANGE); + break; + } + } +#endif } } @@ -62,36 +123,53 @@ bool MomentaryButton::isPressed(int level) const { } } +void MomentaryButton::applyTransition(int btn, unsigned long at) { + if (btn == prev) return; + int event = BUTTON_EVENT_NONE; // kept for parity with the original inline logic below + if (isPressed(btn)) { + down_at = at; + } else { + // button UP + if (_long_millis > 0) { + if (down_at > 0 && (unsigned long)(at - down_at) < (unsigned long)_long_millis) { // only a CLICK if still within the long_press millis + _click_count++; + _last_click_time = at; + _pending_click = true; + } + } else { + _click_count++; + _last_click_time = at; + _pending_click = true; + } + if (event == BUTTON_EVENT_CLICK && cancel) { + event = BUTTON_EVENT_NONE; + _click_count = 0; + _last_click_time = 0; + _pending_click = false; + } + down_at = 0; + } + prev = btn; +} + int MomentaryButton::check(bool repeat_click) { if (_pin < 0) return BUTTON_EVENT_NONE; int event = BUTTON_EVENT_NONE; - int btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); - if (btn != prev) { - if (isPressed(btn)) { - down_at = millis(); - } else { - // button UP - if (_long_millis > 0) { - if (down_at > 0 && (unsigned long)(millis() - down_at) < _long_millis) { // only a CLICK if still within the long_press millis - _click_count++; - _last_click_time = millis(); - _pending_click = true; - } - } else { - _click_count++; - _last_click_time = millis(); - _pending_click = true; - } - if (event == BUTTON_EVENT_CLICK && cancel) { - event = BUTTON_EVENT_NONE; - _click_count = 0; - _last_click_time = 0; - _pending_click = false; - } - down_at = 0; - } - prev = btn; + int btn; +#ifdef BUTTON_USE_INTERRUPTS + if (_isr_slot >= 0) { + // Replay edges the ISR caught while the main loop was off doing something + // blocking (e-ink refresh) — each keeps its own capture timestamp so + // click-duration / multi-click-window math still lines up correctly. + uint8_t lvl; uint32_t at; + while (popEdge(lvl, at)) applyTransition(lvl, at); + btn = prev; // edges already caught us up to the live level + } else +#endif + { + btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); + applyTransition(btn, millis()); } if (!isPressed(btn) && cancel) { // always clear the pending 'cancel' once button is back in UP state cancel = 0; diff --git a/src/helpers/ui/MomentaryButton.h b/src/helpers/ui/MomentaryButton.h index 358a343b..cbf6a29c 100644 --- a/src/helpers/ui/MomentaryButton.h +++ b/src/helpers/ui/MomentaryButton.h @@ -21,6 +21,41 @@ class MomentaryButton { bool _pending_click; bool isPressed(int level) const; + void applyTransition(int btn, unsigned long at); + +#ifdef BUTTON_USE_INTERRUPTS + // GPIO-IRQ edge capture: boards whose display blocks the main loop for a + // long time per refresh (e-ink) can miss an entire press+release cycle if + // check() only ever samples the live pin. The ISR latches edges with their + // own timestamp into a small ring buffer; check() replays them so no tap + // gets lost while the loop is stuck waiting on the panel. + int8_t _isr_slot = -1; + static const uint8_t EDGE_BUF_SIZE = 16; + volatile uint8_t _edge_level[EDGE_BUF_SIZE]; + volatile uint32_t _edge_time[EDGE_BUF_SIZE]; + volatile uint8_t _edge_head = 0, _edge_tail = 0; + volatile uint32_t _last_isr_edge = 0; + + void pushEdge(uint8_t level, uint32_t at); + bool popEdge(uint8_t &level, uint32_t &at); + void isrHandler(); + static MomentaryButton* _isr_table[8]; + +public: + // MAX_ISR_BUTTONS/isrTrampolineN must be public: attachInterrupt() needs a + // plain free-function pointer, taken from a file-scope table outside the + // class (see MomentaryButton.cpp), which needs both to size and fill itself. + static const uint8_t MAX_ISR_BUTTONS = 8; + static void isrTrampoline0(); + static void isrTrampoline1(); + static void isrTrampoline2(); + static void isrTrampoline3(); + static void isrTrampoline4(); + static void isrTrampoline5(); + static void isrTrampoline6(); + static void isrTrampoline7(); +private: +#endif public: MomentaryButton(int8_t pin, int long_press_mills=0, bool reverse=false, bool pulldownup=false, bool multiclick=true); diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 9570b259..abf0f300 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -29,6 +29,7 @@ build_flags = ${nrf52_base.build_flags} -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=GxEPDDisplay + -D BUTTON_USE_INTERRUPTS=1 -D UI_HAS_JOYSTICK=1 -D UI_HAS_JOYSTICK_UPDOWN=1 -D PIN_BUZZER=12