From 06e741b310efcf479fe3c65d98c70769a9ed6130 Mon Sep 17 00:00:00 2001 From: pmow Date: Fri, 9 Oct 2026 08:48:03 -1000 Subject: [PATCH] fix(ui-new): keep CardKB key presses during an e-ink refresh While the loop task is inside a display refresh (seconds on a big e-ink panel) the CardKB isn't polled, and the keyboard's own one-key buffer drops every press but the last. A small FreeRTOS task polls the CardKB every 10 ms while a refresh is in progress, into a 16-entry queue that the loop drains afterwards. A mutex guards the shared Wire1 bus against querySensors(). The task is created after the screens so its stack doesn't starve their allocations, and the loop falls back to the old direct polling if it can't be created. Tested on hardware: Wio Tracker L1 + CardKB, several quick presses during an 800x480 refresh all arrive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KxAZVAbhhYr18Dd4guTQM1 --- .../companion_radio/ui-new/StatusScreen.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 121 +++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 22 ++++ 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/StatusScreen.h b/examples/companion_radio/ui-new/StatusScreen.h index 157c8f9b..59c281a1 100644 --- a/examples/companion_radio/ui-new/StatusScreen.h +++ b/examples/companion_radio/ui-new/StatusScreen.h @@ -229,7 +229,7 @@ private: // Sensors: every reading but the battery (above) and the position (GPS). if (!_lpp_at || millis() - _lpp_at > 5000) { // both passes (measure, draw) read one query _lpp.reset(); - sensors.querySensors(0xFF, _lpp); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, _lpp); } // shares the CardKB's bus _lpp_at = millis() | 1; } static const struct { uint8_t type; const char* name; } NAMES[] = { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5a923257..0d8c8044 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -8,6 +8,20 @@ #include "../Features.h" #include "../GeoUtils.h" #include "target.h" + +// The CardKB background poll task (see UITask::cardkbTask) shares its I2C bus +// with the environment sensors, and some screens query those sensors while +// rendering -- exactly when that task is active. Holding this lock around a +// sensor query makes the two take turns. No-op on builds without the task. +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) +static void* g_cardkb_bus_mutex = nullptr; // SemaphoreHandle_t, set by startCardKBCapture() +struct CardKBBusLock { + CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreTake((SemaphoreHandle_t)g_cardkb_bus_mutex, portMAX_DELAY); } + ~CardKBBusLock() { if (g_cardkb_bus_mutex) xSemaphoreGive((SemaphoreHandle_t)g_cardkb_bus_mutex); } +}; +#else +struct CardKBBusLock { }; +#endif #ifdef WIFI_SSID #include #endif @@ -824,7 +838,7 @@ class HomeScreen : public UIScreen { sensors_lpp.reset(); sensors_nb = 0; sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); - sensors.querySensors(0xFF, sensors_lpp); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, sensors_lpp); } LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize()); uint8_t channel, type; while(reader.readHeader(channel, type)) { @@ -2039,6 +2053,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(PIN_GPIO1) gpio_screen = new GpioScreen(this, node_prefs); #endif + startCardKBCapture(); // after every screen: an optional task must not starve one applyBrightness(); applyRotation(); applyFullRefreshInterval(); @@ -2720,7 +2735,11 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m { NodePrefs* np = the_mesh.getNodePrefs(); in.gps_on = np && np->gps_enabled; } // off: no stale satellite count #endif if (telemetry::isSensor(field)) { - if (!lpp) { static CayenneLPP s_lpp(200); s_lpp.reset(); sensors.querySensors(0xFF, s_lpp); lpp = &s_lpp; } + if (!lpp) { + static CayenneLPP s_lpp(200); s_lpp.reset(); + { CardKBBusLock bus_lock; sensors.querySensors(0xFF, s_lpp); } + lpp = &s_lpp; + } in.lpp = lpp->getBuffer(); in.lpp_len = lpp->getSize(); } @@ -2887,12 +2906,100 @@ void UITask::pollCardKB() { // Polling every loop() iteration (same as a digital button's check(), which // has no throttle either) just shrinks that miss window down to exactly the // render() duration instead of render()+30ms. + // First, keys the background task captured while the display blocked, in order. + while (_cardkb_q_tail != _cardkb_q_head) { + uint8_t q = _cardkb_q[_cardkb_q_tail]; + _cardkb_q_tail = (uint8_t)((_cardkb_q_tail + 1) % CARDKB_Q_LEN); + handleCardKBByte(q); + } + uint8_t raw; + if (!readCardKBEdge(raw)) return; + handleCardKBByte(raw); +#endif +} + +#if defined(CARDKB_I2C) +bool UITask::readCardKBEdge(uint8_t& raw) { CARDKB_I2C.requestFrom(0x5F, 1); - if (!CARDKB_I2C.available()) return; - uint8_t raw = CARDKB_I2C.read(); - if (raw == _cardkb_last_raw) return; // still held (or still released) -- no new edge + if (!CARDKB_I2C.available()) return false; + raw = CARDKB_I2C.read(); + if (raw == _cardkb_last_raw) return false; // still held (or still released) -- no new edge _cardkb_last_raw = raw; - if (raw == 0) return; // key just released, nothing to enqueue + return raw != 0; // 0 = key just released, nothing to enqueue +} + +// Single producer (the poll task) / single consumer (pollCardKB() in the +// loop) ring -- byte-sized indices, so no locking needed. +void UITask::cardkbQueuePush(uint8_t raw) { + uint8_t next = (uint8_t)((_cardkb_q_head + 1) % CARDKB_Q_LEN); + if (next == _cardkb_q_tail) return; // full: drop (16 keys within one refresh) + _cardkb_q[_cardkb_q_head] = raw; + _cardkb_q_head = next; +} + +#if defined(NRF52_PLATFORM) +void UITask::cardkbTask(void* self) { + UITask* t = (UITask*)self; + SemaphoreHandle_t m = (SemaphoreHandle_t)t->_cardkb_mutex; + for (;;) { + if (t->_cardkb_bg_active && xSemaphoreTake(m, portMAX_DELAY) == pdTRUE) { + if (t->_cardkb_bg_active) { // re-check: loop may have just finished + uint8_t raw; + if (t->readCardKBEdge(raw)) t->cardkbQueuePush(raw); + } + xSemaphoreGive(m); + } + vTaskDelay(ms2tick(10)); // a keypress lasts ~50+ ms, so 10 ms never misses one + } +} +#endif +#endif + +// Keep sampling the CardKB while the display blocks the loop, so keys typed +// during a (long, on big panels) refresh aren't lost. Called LAST in begin(): +// the heap is tight on large-panel builds, and every screen is allocated with +// `new` -- a failed allocation leaves that screen silently unopenable. Creating +// the optional poll task afterwards means a short heap costs the task (the +// CardKB is then only read between frames, as without it), never a screen. +void UITask::startCardKBCapture() { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_has_cardkb) return; + SemaphoreHandle_t m = xSemaphoreCreateMutex(); + if (!m) return; + // Publish the mutex BEFORE creating the task: at higher priority it starts + // running inside xTaskCreate() and reads _cardkb_mutex straight away. + _cardkb_mutex = (void*)m; + // TASK_PRIO_NORMAL (2) preempts the loop task (TASK_PRIO_LOW, 1); BLE runs higher. + if (xTaskCreate(&UITask::cardkbTask, "cardkb", 512, this, TASK_PRIO_NORMAL, NULL) == pdPASS) { + g_cardkb_bus_mutex = m; // shared with CardKBBusLock (sensor queries) + return; + } + _cardkb_mutex = nullptr; // no task -> setCardKBBackground() stays a no-op + vSemaphoreDelete(m); +#endif +} + +// Hand CardKB sampling to the background task (on = loop is about to block on +// the display) or take it back (off). Taking the mutex on the way out waits for +// any in-flight background I2C read to finish before the loop touches the bus. +void UITask::setCardKBBackground(bool on) { +#if defined(CARDKB_I2C) && defined(NRF52_PLATFORM) + if (!_cardkb_mutex) return; + if (on) { + _cardkb_bg_active = true; + } else { + SemaphoreHandle_t m = (SemaphoreHandle_t)_cardkb_mutex; + xSemaphoreTake(m, portMAX_DELAY); + _cardkb_bg_active = false; + xSemaphoreGive(m); + } +#else + (void)on; +#endif +} + +void UITask::handleCardKBByte(uint8_t raw) { +#if defined(CARDKB_I2C) // Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid // entirely, and is meant to guarantee joystick-free operation: while it's @@ -3460,6 +3567,7 @@ void UITask::loop() { if (now != ext) { ext = now; _next_refresh = 0; } } if (_display != NULL && _display->isOn()) { + setCardKBBackground(true); // keep sampling the keyboard while the frame blocks // Lock-screen password prompt if (_locked && _unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { cancelUnlockPrompt(); // Cancel unlock attempt on idle @@ -3528,6 +3636,7 @@ void UITask::loop() { _display->endFrame(); PERF_T2(); } + setCardKBBackground(false); #if AUTO_OFF_MILLIS > 0 #ifdef KEEP_DISPLAY_ON_USB // Opt-in: refresh the auto-off deadline while externally powered, so the diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 993ac5cd..9202f2ac 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -209,8 +209,30 @@ private: // the last raw byte seen so a held key enqueues exactly one press instead // of one per poll tick. uint8_t _cardkb_last_raw = 0; + // Keys sampled by the background poll task while the display blocks the + // main loop; drained (in order) by the next pollCardKB(). + static const uint8_t CARDKB_Q_LEN = 16; + uint8_t _cardkb_q[CARDKB_Q_LEN]; + volatile uint8_t _cardkb_q_head = 0, _cardkb_q_tail = 0; // shared with the poll task + bool readCardKBEdge(uint8_t& raw); // one I2C read + debounce; true on a new keypress + void cardkbQueuePush(uint8_t raw); // add a sampled key to _cardkb_q (drops if full) +#if defined(NRF52_PLATFORM) + // nRF52 (FreeRTOS): a small higher-priority task samples the CardKB every + // 10 ms while the loop is busy drawing / sending / refreshing the e-ink + // frame -- the whole window, not just the panel's BUSY wait (on an 800x480 + // panel the byte-by-byte SPI transfers alone block for the better part of a + // second). _cardkb_bg_active marks that window; the mutex guarantees the + // task and the loop never use the I2C bus at the same time. Leaves the + // display's busy-wait hook (setBusyPumpFn) to the radio pump. + void* _cardkb_mutex = nullptr; // SemaphoreHandle_t + volatile bool _cardkb_bg_active = false; + static void cardkbTask(void* self); +#endif #endif void pollCardKB(); + void handleCardKBByte(uint8_t raw); + void setCardKBBackground(bool on); // no-op without CardKB / the poll task + void startCardKBCapture(); // end of begin(): start the poll task // Optional magnetic "flip cover" lock: a Hall-effect or reed sensor wired to // any free GPIO, closing (pulling active) when a magnet is near. Entirely