diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d183b2e2..5e5a28b7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1429,6 +1429,21 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(PIN_USER_BTN) user_btn.begin(); #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) analog_btn.begin(); #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() { - char c = 0; // Background delivery: resend pending on-device DMs whose ACK timed out, and // finalise the ✗ marker — runs regardless of which screen is active. ((QuickMsgScreen*)quick_msg)->tickDmResends(); @@ -2003,32 +2032,27 @@ void UITask::loop() { } // eat the Enter — don't pass to curr } else { - c = checkDisplayOn(KEY_ENTER); + enqueueKey(checkDisplayOn(KEY_ENTER)); } } 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 - ev = joystick_up.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot)); - } - ev = joystick_down.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot)); - } + while (joystick_up.check() == BUTTON_EVENT_CLICK) + enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_UP, joy_rot))); + while (joystick_down.check() == BUTTON_EVENT_CLICK) + enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_DOWN, joy_rot))); #endif - ev = joystick_left.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot)); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot)); + while ((ev = joystick_left.check()) != BUTTON_EVENT_NONE) { + if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_LEFT, joy_rot))); + else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_LEFT, joy_rot))); break; } } - ev = joystick_right.check(); - if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot)); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot)); + while ((ev = joystick_right.check()) != BUTTON_EVENT_NONE) { + if (ev == BUTTON_EVENT_CLICK) enqueueKey(checkDisplayOn(rotateJoystickKey(KEY_RIGHT, joy_rot))); + else { if (ev == BUTTON_EVENT_LONG_PRESS) enqueueKey(handleLongPress(rotateJoystickKey(KEY_RIGHT, joy_rot))); break; } } if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { _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_used = false; } else { - c = checkDisplayOn(KEY_CANCEL); + enqueueKey(checkDisplayOn(KEY_CANCEL)); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - if (!_locked) c = handleTripleClick(KEY_SELECT); + if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT)); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_NEXT); + enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); + enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { - c = handleDoubleClick(KEY_PREV); + enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + enqueueKey(handleTripleClick(KEY_SELECT)); } #endif #if defined(PIN_USER_BTN_ANA) if (millis() - _analogue_pin_read_millis > 10) { int ev = analog_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_NEXT); + enqueueKey(checkDisplayOn(KEY_NEXT)); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); + enqueueKey(handleLongPress(KEY_ENTER)); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { - c = handleDoubleClick(KEY_PREV); + enqueueKey(handleDoubleClick(KEY_PREV)); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + enqueueKey(handleTripleClick(KEY_SELECT)); } _analogue_pin_read_millis = millis(); } @@ -2085,32 +2109,28 @@ void UITask::loop() { #endif // 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. - if (c != 0 && isRinging()) { + // screen — and the queued keys are swallowed so they don't also act on the view. + if (_kq_head != _kq_tail && isRinging()) { dismissRing(); - c = 0; + _kq_head = _kq_tail = 0; _next_refresh = 0; } - if (c != 0) { + if (_kq_head != _kq_tail) { 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 -#ifdef PIN_BUZZER - if (buzzer.isPlaying()) { - // Keep the next render at least 300 ms away so the blocking e-ink endFrame() - // doesn't extend the current note. 300 ms covers the slowest note at 120 BPM (1/4). - unsigned long deadline = millis() + 300; - if (_next_refresh < deadline) _next_refresh = deadline; - } else { - _next_refresh = 100; // trigger refresh immediately - } -#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; + // Note timing no longer depends on render cadence (TIMER1 IRQ advances + // notes directly — see buzzer.cpp), so a redraw right after a keypress + // can't clip a note; no need to hold it back while buzzer.isPlaying(). + _next_refresh = 100; // trigger refresh immediately + } else { + _kq_head = _kq_tail = 0; // locked or no screen: eat all queued keys + // Locked: wake window is set only when display first turns on + if (_locked) _next_refresh = 0; } } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f09cde43..8bf140da 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -174,6 +174,17 @@ class UITask : public AbstractUITask { char handleDoubleClick(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); public: diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 9d01e5b0..db61c2dd 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,24 @@ 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) { + // 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) { 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); + // 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. + int live = digitalRead(_pin); + if (live != prev) applyTransition(live, millis()); + 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; @@ -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) { // still pressed - wait for button release before processing clicks return event; 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/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 02deb5da..46e79de0 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -9,6 +9,20 @@ void genericBuzzer::begin() { #endif pinMode(PIN_BUZZER, OUTPUT); 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(); } @@ -150,7 +164,41 @@ void genericBuzzer::_nrfStopPwm() { _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) { + _disarmNoteTimer(); // drop any in-flight/pending note advance before reconfiguring _nrfStopPwm(); if (!melody || !*melody) { _rtttl_done = true; return; } const char* notes; @@ -163,7 +211,7 @@ void genericBuzzer::_nrfBegin(const char* melody) { void genericBuzzer::_nrfAdvance() { uint16_t freq; uint32_t 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(); } else { _nrfStopPwm(); @@ -191,12 +239,20 @@ void genericBuzzer::playForced(const char* melody) { bool genericBuzzer::isPlaying() { return !_rtttl_done; } void genericBuzzer::stop() { + _disarmNoteTimer(); // ensure no latched note-advance fires after we stop _nrfStopPwm(); _rtttl_done = true; } -void genericBuzzer::loop() { - if (!_rtttl_done && (millis() >= _note_end_ms)) _nrfAdvance(); +// No-op: TIMER1's compare interrupt (_timer1ISR -> _nrfAdvance) now drives +// 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) { diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 45945a5d..80583de5 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -54,7 +54,6 @@ class genericBuzzer const char* _rtttl_pos = nullptr; bool _rtttl_done = true; bool _pwm_on = false; - unsigned long _note_end_ms = 0; uint8_t _def_dur = 4; uint8_t _def_oct = 5; uint16_t _def_bpm = 120; @@ -69,5 +68,21 @@ class genericBuzzer 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, 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 }; 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