Files
MeshCore-Solo/src/helpers/ui/MomentaryButton.cpp
T
MarekZegare4andClaude Opus 4.8 f31596da1a fix(eink): debounce edge capture + self-heal so one press isn't a double-tap
A single button press could surface as two CLICKs on the e-ink build, most
visibly as start+stop on the stopwatch. Two contact-bounce paths fed the
IRQ edge-capture machinery a phantom press/release pair:

- a bounce edge accepted just after a clean release was replayed as a real
  press — ISR_DEBOUNCE_MS (5 ms) was too short for the joystick switch; raised
  to 25 ms so the settling burst is swallowed.
- the live-pin self-heal reconciled prev against a single raw digitalRead,
  which can sample a bouncing contact mid-flap and synthesise a transition.
  It now acts only once the divergence has been stable for ISR_DEBOUNCE_MS, so
  a momentary read can't inject a click; a genuinely lost edge still heals
  (~25 ms later) so the button can't stick.

Both thresholds stay far below any human tap cadence (>100 ms), so rapid
multi-tap navigation still registers every tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:40:44 +02:00

267 lines
8.9 KiB
C++

#include "MomentaryButton.h"
#define MULTI_CLICK_WINDOW_MS 280
#ifdef BUTTON_USE_INTERRUPTS
// Contact-bounce guard for the edge-capture ISR. Must be long enough to swallow
// a switch's full settling burst, otherwise a bounce edge accepted just after a
// clean release is replayed as a phantom press and — once the live-pin self-heal
// pulls the level back — surfaces as a second CLICK (seen as a double-tap, e.g.
// start+stop on the stopwatch). 5 ms was too tight for the joystick switch.
// Still far below any human tap cadence (>100 ms), so real fast bursts are kept.
#define ISR_DEBOUNCE_MS 25
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;
_pull = pulldownup;
down_at = 0;
prev = _reverse ? HIGH : LOW;
cancel = 0;
_long_millis = long_press_millis;
_threshold = 0;
_click_count = 0;
_last_click_time = 0;
_multi_click_window = multiclick ? MULTI_CLICK_WINDOW_MS : 0;
_pending_click = false;
}
MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_threshold) {
_pin = pin;
_reverse = false;
_pull = false;
down_at = 0;
prev = LOW;
cancel = 0;
_long_millis = long_press_millis;
_threshold = analog_threshold;
_click_count = 0;
_last_click_time = 0;
_multi_click_window = MULTI_CLICK_WINDOW_MS;
_pending_click = false;
}
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) {
// The nRF52 has only 8 GPIOTE channels, shared across every pin using
// attachInterrupt() (the radio's DIO1 takes one too). It returns 0 when
// they're exhausted — only claim the trampoline slot if a channel was
// actually allocated. Otherwise leave _isr_slot = -1 so check() uses the
// polling path: that button still works, it just won't capture edges
// that land during a blocking display refresh. Retrying another index is
// pointless — the channel pool, not the trampoline slot, is what ran out.
if (attachInterrupt(digitalPinToInterrupt(_pin), ISR_TRAMPOLINES[i], CHANGE) != 0) {
_isr_table[i] = this;
_isr_slot = i;
}
break;
}
}
#endif
}
}
bool MomentaryButton::isPressed() const {
int btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin);
return isPressed(btn);
}
void MomentaryButton::cancelClick() {
cancel = 1;
down_at = 0;
_click_count = 0;
_last_click_time = 0;
_pending_click = false;
}
bool MomentaryButton::isPressed(int level) const {
if (_threshold > 0) {
return level;
}
if (_reverse) {
return level == LOW;
} else {
return level != LOW;
}
}
void MomentaryButton::applyTransition(int btn, unsigned long at) {
if (btn == prev) return;
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;
}
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;
#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);
// Self-heal: if an edge was ever lost (buffer overflow, a debounce-dropped
// settling edge, or a missed GPIOTE event), prev would otherwise stay
// diverged from the hardware until the next captured edge — a stuck button.
// Reconcile against the live pin, but only once the divergence has been
// STABLE for ISR_DEBOUNCE_MS: a single raw read can catch a contact
// mid-bounce and would otherwise synthesise a phantom press/release pair
// (a double-click — e.g. start+stop on the stopwatch). A no-op whenever the
// edge stream already matches the pin, which is the normal case.
int live = digitalRead(_pin);
uint32_t now = millis();
if (live != prev) {
if (!_healing || live != _heal_level) {
_healing = true;
_heal_level = (uint8_t)live;
_heal_since = now;
} else if ((uint32_t)(now - _heal_since) >= ISR_DEBOUNCE_MS) {
applyTransition(live, now);
_healing = false;
}
} else {
_healing = false;
}
btn = prev;
} 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;
}
if (_long_millis > 0 && down_at > 0 && (unsigned long)(millis() - down_at) >= _long_millis) {
if (_pending_click) {
// long press during multi-click detection - cancel pending clicks
cancelClick();
} else {
event = BUTTON_EVENT_LONG_PRESS;
down_at = 0;
_click_count = 0;
_last_click_time = 0;
_pending_click = false;
}
}
if (down_at > 0 && repeat_click) {
unsigned long diff = (unsigned long)(millis() - down_at);
if (diff >= 700) {
event = BUTTON_EVENT_CLICK; // wait 700 millis before repeating the click events
}
}
if (_multi_click_window == 0) {
// No multi-click semantics: every completed press is an independent CLICK.
// When a burst of taps is captured during a blocking refresh (e-ink), all
// their edges replay into _click_count in a single check(); emit them one
// CLICK per call (decrementing) instead of collapsing into a lone
// double/triple event the caller would ignore — so each tap survives as a
// discrete key. Waits for release (down_at == 0) so a held button doesn't
// fire mid-press.
if (_pending_click && down_at == 0) {
event = BUTTON_EVENT_CLICK;
if (--_click_count == 0) {
_last_click_time = 0;
_pending_click = false;
}
}
} else if (_pending_click && (millis() - _last_click_time) >= _multi_click_window) {
if (down_at > 0) {
// still pressed - wait for button release before processing clicks
return event;
}
switch (_click_count) {
case 1:
event = BUTTON_EVENT_CLICK;
break;
case 2:
event = BUTTON_EVENT_DOUBLE_CLICK;
break;
case 3:
event = BUTTON_EVENT_TRIPLE_CLICK;
break;
default:
// For 4+ clicks, treat as triple click?
event = BUTTON_EVENT_TRIPLE_CLICK;
break;
}
_click_count = 0;
_last_click_time = 0;
_pending_click = false;
}
return event;
}