mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-08-03 10:46:12 +00:00
Merge branch 'fix/eink-button-irq-edge-capture'
This commit is contained in:
@@ -1429,6 +1429,21 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
|||||||
#if defined(PIN_USER_BTN)
|
#if defined(PIN_USER_BTN)
|
||||||
user_btn.begin();
|
user_btn.begin();
|
||||||
#endif
|
#endif
|
||||||
|
#if UI_HAS_JOYSTICK
|
||||||
|
// The directional joystick + Back share the same MomentaryButton machinery as
|
||||||
|
// user_btn but were never begin()'d — they only worked because the pins
|
||||||
|
// default to INPUT and the board has external pulls. That left them on the
|
||||||
|
// polling path: with BUTTON_USE_INTERRUPTS (e-ink) they'd silently never
|
||||||
|
// attach a GPIOTE channel, so edges landing during a blocking panel refresh
|
||||||
|
// were lost. begin() sets pinMode and claims an IRQ slot for each.
|
||||||
|
joystick_left.begin();
|
||||||
|
joystick_right.begin();
|
||||||
|
back_btn.begin();
|
||||||
|
#if UI_HAS_JOYSTICK_UPDOWN
|
||||||
|
joystick_up.begin();
|
||||||
|
joystick_down.begin();
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
#if defined(PIN_USER_BTN_ANA)
|
#if defined(PIN_USER_BTN_ANA)
|
||||||
analog_btn.begin();
|
analog_btn.begin();
|
||||||
#endif
|
#endif
|
||||||
@@ -1970,8 +1985,22 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UITask::enqueueKey(char c) {
|
||||||
|
if (c == 0) return;
|
||||||
|
uint8_t next = (_kq_head + 1) % KEY_QUEUE_SIZE;
|
||||||
|
if (next == _kq_tail) return; // full: drop newest rather than clobber unprocessed keys
|
||||||
|
_key_queue[_kq_head] = c;
|
||||||
|
_kq_head = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UITask::dequeueKey(char& c) {
|
||||||
|
if (_kq_tail == _kq_head) return false;
|
||||||
|
c = _key_queue[_kq_tail];
|
||||||
|
_kq_tail = (_kq_tail + 1) % KEY_QUEUE_SIZE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void UITask::loop() {
|
void UITask::loop() {
|
||||||
char c = 0;
|
|
||||||
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
||||||
// finalise the ✗ marker — runs regardless of which screen is active.
|
// finalise the ✗ marker — runs regardless of which screen is active.
|
||||||
((QuickMsgScreen*)quick_msg)->tickDmResends();
|
((QuickMsgScreen*)quick_msg)->tickDmResends();
|
||||||
@@ -2003,32 +2032,27 @@ void UITask::loop() {
|
|||||||
}
|
}
|
||||||
// eat the Enter — don't pass to curr
|
// eat the Enter — don't pass to curr
|
||||||
} else {
|
} else {
|
||||||
c = checkDisplayOn(KEY_ENTER);
|
enqueueKey(checkDisplayOn(KEY_ENTER));
|
||||||
}
|
}
|
||||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||||
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
|
enqueueKey(handleLongPress(KEY_ENTER)); // REVISIT: could be mapped to different key code
|
||||||
}
|
}
|
||||||
|
// Drain each direction fully: a burst of taps captured during a blocking
|
||||||
|
// refresh replays as several CLICKs, queued here and applied before one
|
||||||
|
// redraw (see enqueueKey / the dispatch at the end of loop()).
|
||||||
#if UI_HAS_JOYSTICK_UPDOWN
|
#if UI_HAS_JOYSTICK_UPDOWN
|
||||||
ev = joystick_up.check();
|
while (joystick_up.check() == BUTTON_EVENT_CLICK)
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot)));
|
||||||
c = checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot));
|
while (joystick_down.check() == BUTTON_EVENT_CLICK)
|
||||||
}
|
enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot)));
|
||||||
ev = joystick_down.check();
|
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
|
||||||
c = checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot));
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
ev = joystick_left.check();
|
while ((ev = joystick_left.check()) != BUTTON_EVENT_NONE) {
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot)));
|
||||||
c = checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot));
|
else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot))); break; }
|
||||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
||||||
c = handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot));
|
|
||||||
}
|
}
|
||||||
ev = joystick_right.check();
|
while ((ev = joystick_right.check()) != BUTTON_EVENT_NONE) {
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot)));
|
||||||
c = checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot));
|
else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot))); break; }
|
||||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
|
||||||
c = handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot));
|
|
||||||
}
|
}
|
||||||
if (_lock_seq_used && millis() - _lock_seq_ms > 5000) {
|
if (_lock_seq_used && millis() - _lock_seq_ms > 5000) {
|
||||||
_lock_seq_used = false; // safety reset if Back release event was missed
|
_lock_seq_used = false; // safety reset if Back release event was missed
|
||||||
@@ -2040,34 +2064,34 @@ void UITask::loop() {
|
|||||||
_lock_seq_count = 0;
|
_lock_seq_count = 0;
|
||||||
_lock_seq_used = false;
|
_lock_seq_used = false;
|
||||||
} else {
|
} else {
|
||||||
c = checkDisplayOn(KEY_CANCEL);
|
enqueueKey(checkDisplayOn(KEY_CANCEL));
|
||||||
}
|
}
|
||||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||||
if (!_locked) c = handleTripleClick(KEY_SELECT);
|
if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT));
|
||||||
}
|
}
|
||||||
#elif defined(PIN_USER_BTN)
|
#elif defined(PIN_USER_BTN)
|
||||||
int ev = user_btn.check();
|
int ev = user_btn.check();
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
if (ev == BUTTON_EVENT_CLICK) {
|
||||||
c = checkDisplayOn(KEY_NEXT);
|
enqueueKey(checkDisplayOn(KEY_NEXT));
|
||||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||||
c = handleLongPress(KEY_ENTER);
|
enqueueKey(handleLongPress(KEY_ENTER));
|
||||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||||
c = handleDoubleClick(KEY_PREV);
|
enqueueKey(handleDoubleClick(KEY_PREV));
|
||||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||||
c = handleTripleClick(KEY_SELECT);
|
enqueueKey(handleTripleClick(KEY_SELECT));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if defined(PIN_USER_BTN_ANA)
|
#if defined(PIN_USER_BTN_ANA)
|
||||||
if (millis() - _analogue_pin_read_millis > 10) {
|
if (millis() - _analogue_pin_read_millis > 10) {
|
||||||
int ev = analog_btn.check();
|
int ev = analog_btn.check();
|
||||||
if (ev == BUTTON_EVENT_CLICK) {
|
if (ev == BUTTON_EVENT_CLICK) {
|
||||||
c = checkDisplayOn(KEY_NEXT);
|
enqueueKey(checkDisplayOn(KEY_NEXT));
|
||||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||||
c = handleLongPress(KEY_ENTER);
|
enqueueKey(handleLongPress(KEY_ENTER));
|
||||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||||
c = handleDoubleClick(KEY_PREV);
|
enqueueKey(handleDoubleClick(KEY_PREV));
|
||||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||||
c = handleTripleClick(KEY_SELECT);
|
enqueueKey(handleTripleClick(KEY_SELECT));
|
||||||
}
|
}
|
||||||
_analogue_pin_read_millis = millis();
|
_analogue_pin_read_millis = millis();
|
||||||
}
|
}
|
||||||
@@ -2085,32 +2109,28 @@ void UITask::loop() {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
|
// A ringing alarm/timer is dismissed by ANY key, even when locked or on another
|
||||||
// screen — and that key is swallowed so it doesn't also act on the current view.
|
// screen — and the queued keys are swallowed so they don't also act on the view.
|
||||||
if (c != 0 && isRinging()) {
|
if (_kq_head != _kq_tail && isRinging()) {
|
||||||
dismissRing();
|
dismissRing();
|
||||||
c = 0;
|
_kq_head = _kq_tail = 0;
|
||||||
_next_refresh = 0;
|
_next_refresh = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c != 0) {
|
if (_kq_head != _kq_tail) {
|
||||||
if (!_locked && curr) {
|
if (!_locked && curr) {
|
||||||
curr->handleInput(c);
|
// Apply the whole queued burst, then redraw once — N taps captured during
|
||||||
|
// a blocking refresh become N navigation steps at the cost of one refresh.
|
||||||
|
char k;
|
||||||
|
while (dequeueKey(k)) curr->handleInput(k);
|
||||||
{ uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer
|
{ uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer
|
||||||
#ifdef PIN_BUZZER
|
// Note timing no longer depends on render cadence (TIMER1 IRQ advances
|
||||||
if (buzzer.isPlaying()) {
|
// notes directly — see buzzer.cpp), so a redraw right after a keypress
|
||||||
// Keep the next render at least 300 ms away so the blocking e-ink endFrame()
|
// can't clip a note; no need to hold it back while buzzer.isPlaying().
|
||||||
// doesn't extend the current note. 300 ms covers the slowest note at 120 BPM (1/4).
|
_next_refresh = 100; // trigger refresh immediately
|
||||||
unsigned long deadline = millis() + 300;
|
} else {
|
||||||
if (_next_refresh < deadline) _next_refresh = deadline;
|
_kq_head = _kq_tail = 0; // locked or no screen: eat all queued keys
|
||||||
} else {
|
// Locked: wake window is set only when display first turns on
|
||||||
_next_refresh = 100; // trigger refresh immediately
|
if (_locked) _next_refresh = 0;
|
||||||
}
|
|
||||||
#else
|
|
||||||
_next_refresh = 100; // trigger refresh
|
|
||||||
#endif
|
|
||||||
} else if (_locked) {
|
|
||||||
// Locked: eat all keys — wake window is set only when display first turns on
|
|
||||||
_next_refresh = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,17 @@ class UITask : public AbstractUITask {
|
|||||||
char handleDoubleClick(char c);
|
char handleDoubleClick(char c);
|
||||||
char handleTripleClick(char c);
|
char handleTripleClick(char c);
|
||||||
|
|
||||||
|
// Key FIFO: a burst of taps captured during a blocking refresh (e-ink) is
|
||||||
|
// drained from the buttons into this queue, then all keys are applied before
|
||||||
|
// a single redraw — so rapid navigation steps neither get lost nor cost one
|
||||||
|
// slow refresh each. Also fixes losing a key when two buttons fire in the
|
||||||
|
// same loop iteration.
|
||||||
|
static const uint8_t KEY_QUEUE_SIZE = 16;
|
||||||
|
char _key_queue[KEY_QUEUE_SIZE];
|
||||||
|
uint8_t _kq_head = 0, _kq_tail = 0;
|
||||||
|
void enqueueKey(char c);
|
||||||
|
bool dequeueKey(char& c);
|
||||||
|
|
||||||
void setCurrScreen(UIScreen* c);
|
void setCurrScreen(UIScreen* c);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -2,6 +2,57 @@
|
|||||||
|
|
||||||
#define MULTI_CLICK_WINDOW_MS 280
|
#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) {
|
MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse, bool pulldownup, bool multiclick) {
|
||||||
_pin = pin;
|
_pin = pin;
|
||||||
_reverse = reverse;
|
_reverse = reverse;
|
||||||
@@ -35,6 +86,24 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_t
|
|||||||
void MomentaryButton::begin() {
|
void MomentaryButton::begin() {
|
||||||
if (_pin >= 0 && _threshold == 0) {
|
if (_pin >= 0 && _threshold == 0) {
|
||||||
pinMode(_pin, _pull ? (_reverse ? INPUT_PULLUP : INPUT_PULLDOWN) : INPUT);
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,36 +131,53 @@ bool MomentaryButton::isPressed(int level) const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
int MomentaryButton::check(bool repeat_click) {
|
||||||
if (_pin < 0) return BUTTON_EVENT_NONE;
|
if (_pin < 0) return BUTTON_EVENT_NONE;
|
||||||
|
|
||||||
int event = BUTTON_EVENT_NONE;
|
int event = BUTTON_EVENT_NONE;
|
||||||
int btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin);
|
int btn;
|
||||||
if (btn != prev) {
|
#ifdef BUTTON_USE_INTERRUPTS
|
||||||
if (isPressed(btn)) {
|
if (_isr_slot >= 0) {
|
||||||
down_at = millis();
|
// Replay edges the ISR caught while the main loop was off doing something
|
||||||
} else {
|
// blocking (e-ink refresh) — each keeps its own capture timestamp so
|
||||||
// button UP
|
// click-duration / multi-click-window math still lines up correctly.
|
||||||
if (_long_millis > 0) {
|
uint8_t lvl; uint32_t at;
|
||||||
if (down_at > 0 && (unsigned long)(millis() - down_at) < _long_millis) { // only a CLICK if still within the long_press millis
|
while (popEdge(lvl, at)) applyTransition(lvl, at);
|
||||||
_click_count++;
|
// Self-heal: if an edge was ever lost (buffer overflow, a debounce-dropped
|
||||||
_last_click_time = millis();
|
// settling edge, or a missed GPIOTE event), prev would otherwise stay
|
||||||
_pending_click = true;
|
// 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
|
||||||
} else {
|
// matches it, which is the normal case.
|
||||||
_click_count++;
|
int live = digitalRead(_pin);
|
||||||
_last_click_time = millis();
|
if (live != prev) applyTransition(live, millis());
|
||||||
_pending_click = true;
|
btn = prev;
|
||||||
}
|
} else
|
||||||
if (event == BUTTON_EVENT_CLICK && cancel) {
|
#endif
|
||||||
event = BUTTON_EVENT_NONE;
|
{
|
||||||
_click_count = 0;
|
btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin);
|
||||||
_last_click_time = 0;
|
applyTransition(btn, millis());
|
||||||
_pending_click = false;
|
|
||||||
}
|
|
||||||
down_at = 0;
|
|
||||||
}
|
|
||||||
prev = btn;
|
|
||||||
}
|
}
|
||||||
if (!isPressed(btn) && cancel) { // always clear the pending 'cancel' once button is back in UP state
|
if (!isPressed(btn) && cancel) { // always clear the pending 'cancel' once button is back in UP state
|
||||||
cancel = 0;
|
cancel = 0;
|
||||||
@@ -116,7 +202,22 @@ int MomentaryButton::check(bool repeat_click) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_pending_click && (millis() - _last_click_time) >= _multi_click_window) {
|
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) {
|
if (down_at > 0) {
|
||||||
// still pressed - wait for button release before processing clicks
|
// still pressed - wait for button release before processing clicks
|
||||||
return event;
|
return event;
|
||||||
|
|||||||
@@ -21,6 +21,41 @@ class MomentaryButton {
|
|||||||
bool _pending_click;
|
bool _pending_click;
|
||||||
|
|
||||||
bool isPressed(int level) const;
|
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:
|
public:
|
||||||
MomentaryButton(int8_t pin, int long_press_mills=0, bool reverse=false, bool pulldownup=false, bool multiclick=true);
|
MomentaryButton(int8_t pin, int long_press_mills=0, bool reverse=false, bool pulldownup=false, bool multiclick=true);
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ void genericBuzzer::begin() {
|
|||||||
#endif
|
#endif
|
||||||
pinMode(PIN_BUZZER, OUTPUT);
|
pinMode(PIN_BUZZER, OUTPUT);
|
||||||
digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw
|
digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw
|
||||||
|
#if defined(NRF52_PLATFORM)
|
||||||
|
_isr_instance = this;
|
||||||
|
NRF_TIMER1->TASKS_STOP = 1;
|
||||||
|
NRF_TIMER1->MODE = TIMER_MODE_MODE_Timer << TIMER_MODE_MODE_Pos;
|
||||||
|
NRF_TIMER1->BITMODE = TIMER_BITMODE_BITMODE_32Bit << TIMER_BITMODE_BITMODE_Pos;
|
||||||
|
NRF_TIMER1->PRESCALER = 4; // 16 MHz / 2^4 = 1 MHz -> 1 us/tick
|
||||||
|
NRF_TIMER1->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk | TIMER_SHORTS_COMPARE0_STOP_Msk;
|
||||||
|
NRF_TIMER1->INTENSET = TIMER_INTENSET_COMPARE0_Msk;
|
||||||
|
// Lowest application priority: this only ever reschedules a tone, it must
|
||||||
|
// never contend with anything radio/BLE-timing-critical.
|
||||||
|
NVIC_SetPriority(TIMER1_IRQn, 7);
|
||||||
|
NVIC_ClearPendingIRQ(TIMER1_IRQn);
|
||||||
|
NVIC_EnableIRQ(TIMER1_IRQn);
|
||||||
|
#endif
|
||||||
startup();
|
startup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +164,41 @@ void genericBuzzer::_nrfStopPwm() {
|
|||||||
_pwm_on = false;
|
_pwm_on = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
genericBuzzer* genericBuzzer::_isr_instance = nullptr;
|
||||||
|
|
||||||
|
void genericBuzzer::_armNoteTimer(uint32_t dur_ms) {
|
||||||
|
NRF_TIMER1->TASKS_STOP = 1;
|
||||||
|
NRF_TIMER1->TASKS_CLEAR = 1;
|
||||||
|
NRF_TIMER1->EVENTS_COMPARE[0] = 0;
|
||||||
|
NRF_TIMER1->CC[0] = dur_ms * 1000UL; // 1 us/tick (see PRESCALER in begin())
|
||||||
|
NRF_TIMER1->TASKS_START = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Halt the timer AND drop any interrupt it already latched. Stopping the timer
|
||||||
|
// and clearing EVENTS_COMPARE[0] de-asserts the IRQ source, but an interrupt
|
||||||
|
// the NVIC latched just before we stopped stays pending and would fire one
|
||||||
|
// spurious _nrfAdvance() after we return — skipping the first note of a new
|
||||||
|
// melody, or sounding a blip just after an explicit stop. Order matters: clear
|
||||||
|
// the event (with a read-back to flush the write buffer, per the nRF52 event
|
||||||
|
// anomaly) before clearing the NVIC, else the still-set event re-latches it.
|
||||||
|
void genericBuzzer::_disarmNoteTimer() {
|
||||||
|
NRF_TIMER1->TASKS_STOP = 1;
|
||||||
|
NRF_TIMER1->EVENTS_COMPARE[0] = 0;
|
||||||
|
(void)NRF_TIMER1->EVENTS_COMPARE[0];
|
||||||
|
NVIC_ClearPendingIRQ(TIMER1_IRQn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static member (not a free function) so it can reach private state without a
|
||||||
|
// friend declaration — same trick MomentaryButton's isrTrampolineN() uses.
|
||||||
|
// The real ISR (TIMER1_IRQHandler, below) is just a one-line dispatch to this.
|
||||||
|
void genericBuzzer::_timer1ISR() {
|
||||||
|
NRF_TIMER1->EVENTS_COMPARE[0] = 0;
|
||||||
|
(void)NRF_TIMER1->EVENTS_COMPARE[0]; // flush write buffer so the IRQ doesn't immediately re-fire (nRF52 anomaly)
|
||||||
|
if (_isr_instance) _isr_instance->_nrfAdvance();
|
||||||
|
}
|
||||||
|
|
||||||
void genericBuzzer::_nrfBegin(const char* melody) {
|
void genericBuzzer::_nrfBegin(const char* melody) {
|
||||||
|
_disarmNoteTimer(); // drop any in-flight/pending note advance before reconfiguring
|
||||||
_nrfStopPwm();
|
_nrfStopPwm();
|
||||||
if (!melody || !*melody) { _rtttl_done = true; return; }
|
if (!melody || !*melody) { _rtttl_done = true; return; }
|
||||||
const char* notes;
|
const char* notes;
|
||||||
@@ -163,7 +211,7 @@ void genericBuzzer::_nrfBegin(const char* melody) {
|
|||||||
void genericBuzzer::_nrfAdvance() {
|
void genericBuzzer::_nrfAdvance() {
|
||||||
uint16_t freq; uint32_t dur_ms;
|
uint16_t freq; uint32_t dur_ms;
|
||||||
if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) {
|
if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) {
|
||||||
_note_end_ms = millis() + dur_ms;
|
_armNoteTimer(dur_ms);
|
||||||
if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm();
|
if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm();
|
||||||
} else {
|
} else {
|
||||||
_nrfStopPwm();
|
_nrfStopPwm();
|
||||||
@@ -191,12 +239,20 @@ void genericBuzzer::playForced(const char* melody) {
|
|||||||
bool genericBuzzer::isPlaying() { return !_rtttl_done; }
|
bool genericBuzzer::isPlaying() { return !_rtttl_done; }
|
||||||
|
|
||||||
void genericBuzzer::stop() {
|
void genericBuzzer::stop() {
|
||||||
|
_disarmNoteTimer(); // ensure no latched note-advance fires after we stop
|
||||||
_nrfStopPwm();
|
_nrfStopPwm();
|
||||||
_rtttl_done = true;
|
_rtttl_done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void genericBuzzer::loop() {
|
// No-op: TIMER1's compare interrupt (_timer1ISR -> _nrfAdvance) now drives
|
||||||
if (!_rtttl_done && (millis() >= _note_end_ms)) _nrfAdvance();
|
// note advancement directly, so timing no longer depends on how often (or
|
||||||
|
// whether) the caller's loop() gets to run. Kept as a real method, not
|
||||||
|
// removed, since UITask polls buzzer.loop() unconditionally for both
|
||||||
|
// platforms.
|
||||||
|
void genericBuzzer::loop() {}
|
||||||
|
|
||||||
|
extern "C" void TIMER1_IRQHandler(void) {
|
||||||
|
genericBuzzer::_timer1ISR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void genericBuzzer::setVolume(uint8_t level) {
|
void genericBuzzer::setVolume(uint8_t level) {
|
||||||
|
|||||||
+16
-1
@@ -54,7 +54,6 @@ class genericBuzzer
|
|||||||
const char* _rtttl_pos = nullptr;
|
const char* _rtttl_pos = nullptr;
|
||||||
bool _rtttl_done = true;
|
bool _rtttl_done = true;
|
||||||
bool _pwm_on = false;
|
bool _pwm_on = false;
|
||||||
unsigned long _note_end_ms = 0;
|
|
||||||
uint8_t _def_dur = 4;
|
uint8_t _def_dur = 4;
|
||||||
uint8_t _def_oct = 5;
|
uint8_t _def_oct = 5;
|
||||||
uint16_t _def_bpm = 120;
|
uint16_t _def_bpm = 120;
|
||||||
@@ -69,5 +68,21 @@ class genericBuzzer
|
|||||||
uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms);
|
uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms);
|
||||||
static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct,
|
static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct,
|
||||||
uint16_t& bpm, const char*& notes_start);
|
uint16_t& bpm, const char*& notes_start);
|
||||||
|
|
||||||
|
// TIMER1-driven note advance: a hardware compare interrupt calls
|
||||||
|
// _nrfAdvance() at the exact moment the current note's duration ends,
|
||||||
|
// so playback timing survives a blocking display refresh (e-ink)
|
||||||
|
// instead of waiting for the next loop() poll. Same reasoning as
|
||||||
|
// MomentaryButton's GPIO-IRQ edge capture, just timer- rather than
|
||||||
|
// pin-driven. TIMER0 is reserved by the SoftDevice; TIMER1 is free.
|
||||||
|
void _armNoteTimer(uint32_t dur_ms);
|
||||||
|
void _disarmNoteTimer();
|
||||||
|
static genericBuzzer* _isr_instance;
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Must be public: the extern "C" TIMER1_IRQHandler (see buzzer.cpp) is
|
||||||
|
// a free function — the vector table requires that exact symbol — so
|
||||||
|
// it needs access from outside the class to dispatch into it.
|
||||||
|
static void _timer1ISR();
|
||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ build_flags = ${nrf52_base.build_flags}
|
|||||||
-D MAX_GROUP_CHANNELS=40
|
-D MAX_GROUP_CHANNELS=40
|
||||||
-D OFFLINE_QUEUE_SIZE=256
|
-D OFFLINE_QUEUE_SIZE=256
|
||||||
-D DISPLAY_CLASS=GxEPDDisplay
|
-D DISPLAY_CLASS=GxEPDDisplay
|
||||||
|
-D BUTTON_USE_INTERRUPTS=1
|
||||||
-D UI_HAS_JOYSTICK=1
|
-D UI_HAS_JOYSTICK=1
|
||||||
-D UI_HAS_JOYSTICK_UPDOWN=1
|
-D UI_HAS_JOYSTICK_UPDOWN=1
|
||||||
-D PIN_BUZZER=12
|
-D PIN_BUZZER=12
|
||||||
|
|||||||
Reference in New Issue
Block a user