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>
This commit is contained in:
MarekZegare4
2026-06-30 22:40:44 +02:00
parent 65e5b0af99
commit f31596da1a
2 changed files with 31 additions and 4 deletions

View File

@@ -3,7 +3,13 @@
#define MULTI_CLICK_WINDOW_MS 280
#ifdef BUTTON_USE_INTERRUPTS
#define ISR_DEBOUNCE_MS 5
// 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 };
@@ -168,10 +174,25 @@ int MomentaryButton::check(bool repeat_click) {
// 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; a no-op whenever the edge stream already
// matches it, which is the normal case.
// 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);
if (live != prev) applyTransition(live, millis());
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

View File

@@ -35,6 +35,12 @@ class MomentaryButton {
volatile uint32_t _edge_time[EDGE_BUF_SIZE];
volatile uint8_t _edge_head = 0, _edge_tail = 0;
volatile uint32_t _last_isr_edge = 0;
// Self-heal debounce: only reconcile against the live pin once a divergence
// from `prev` has persisted, so a single raw read of a bouncing contact can't
// inject a phantom press/release pair (a double-click).
bool _healing = false;
uint8_t _heal_level = 0;
uint32_t _heal_since = 0;
void pushEdge(uint8_t level, uint32_t at);
bool popEdge(uint8_t &level, uint32_t &at);