Repository navigation
[Feature request] Keep mesh/BLE serviced while the e-ink display refreshes #3701
Description
On e-ink companions every screen refresh blocks the main loop, so radio/BLE are not serviced meanwhile (~450 ms per partial refresh on my T-Echo with GxEPD2_154_D67, see #3702). GxEPD2 has setBusyCallback(); running the mesh/interface/sensors loops from it during endFrame() cut the longest gap to 60–108 ms on my T-Echo.
Branch (30 lines): akalinin-softvelum/MeshCore@dev...akalinin-softvelum:MeshCore:feature/eink-busy-hook
Only tested on my T-Echo; T-Echo Lite, ThinkNode M5/M8 and RAK4631 only compiled.
Would a PR for this be welcome?
Full analysis (AI-generated with Claude Code)
Use case
On e-ink companions, every screen update blocks the whole main loop for the full panel refresh. GxEPDDisplay::endFrame() calls display.display(true), which waits in GxEPD2's _waitWhileBusy() until the panel releases BUSY. During that time the radio, the companion link (BLE/USB) and sensors are not serviced. App commands wait, and while someone is using the buttons the stalls add up.
Measured on a LilyGo T-Echo (GxEPD2_154_D67, see PR #3702): each partial refresh blocks the loop for about 450 ms (avg 454 ms, max 497 ms over 26 refreshes).
Proposal
GxEPD2 already has a hook for this: setBusyCallback(). _waitWhileBusy() calls it repeatedly instead of delay(1) while it waits for BUSY. The available GxEPD2 versions all have it with the same signature: zinggjm/GxEPD2 1.6.2 and 1.6.9, and the SoulOfNoob fork used by T-Echo Lite.
The proposal is to register a callback only around the partial refresh in endFrame(). The callback runs the non-UI parts of the main loop:
// GxEPDDisplay::endFrame()
if (_busy_hook) display.epd2.setBusyCallback(busyCallback, this);
display.display(true);
display.epd2.setBusyCallback(NULL);
// companion_radio/main.cpp
static void loopDuringDisplayRefresh() {
static bool active = false;
if (active) return;
active = true;
the_mesh.loop();
interface_manager.loop();
sensors.loop();
active = false;
}DisplayDriver gets a no-op virtual void setBusyHook(void (*hook)()), so non-e-ink displays are unaffected.
Reference implementation (30 lines, on top of dev @ afa4b87): https://github.com/akalinin-softvelum/MeshCore/tree/feature/eink-busy-hook (compare: dev...akalinin-softvelum:MeshCore:feature/eink-busy-hook)
Results
Measured on a T-Echo companion BLE build based on main @ 81dbae5, with the same hook code plus temporary timing instrumentation (not part of the branch). The branch itself was only compiled, not run. The metric is the longest gap between consecutive the_mesh.loop() calls while the UI is active:
| longest mesh/BLE service gap | |
|---|---|
| without hook | ~450–500 ms (one full partial refresh) |
| with hook | 60–108 ms, avg 71 ms (38 refreshes) |
During each refresh the hook ran about 310 times, roughly once per millisecond. The remaining 60–108 ms is frame rendering plus the SPI image transfer, which happen outside the busy wait. No errors appeared on the serial log during the test session. Sending and receiving messages over BLE while the screen refreshed was exercised by normal use only, not with a systematic test.
Validation status: please read before relying on this
Only one device was tested at runtime: a single LilyGo T-Echo (nRF52840; radio on SPI, display on SPI1). On every other board, the most this proposal has is a successful compile. A compile does not show that the hook behaves correctly there.
| Board(s) | Status |
|---|---|
| LilyGo T-Echo (companion BLE) | runtime-tested on one unit, measurements above |
LilyGo T-Echo Lite (LilyGo_T-Echo-Lite_companion_radio_ble, SoulOfNoob GxEPD2 fork) |
compile only, not run |
ThinkNode M8 (ThinkNode_M8_companion_radio_ble, GxEPD2 1.6.9) |
compile only, not run |
ThinkNode M5 (ThinkNode_M5_companion_radio_ble, ESP32-S3, GxEPD2 1.6.2) |
compile only, not run |
RAK4631 (RAK_4631_companion_radio_ble, non-e-ink AutoDetectDisplay) |
compile only, checks that the no-op default does not break other display classes |
| ThinkNode M1, Wio Tracker L1 E-Ink, Mesh Pocket | not built, not tested (also use GxEPDDisplay, so they would get the hook) |
E213Display / E290Display boards (Heltec Vision Master etc.) |
not affected: different display class, the hook is a no-op there |
Known gaps, untested on hardware:
- ESP32 boards (e.g. ThinkNode M5): interaction with the task watchdog and FreeRTOS scheduling. The callback ends with
delay(1), the same yield GxEPD2 does without a callback. That is reasoning, not a test. - Boards where the radio and the display share one SPI bus. See point 2 below. The argument comes from reading the GxEPD2 code, not from a test.
begin(), the power-off paths and the screen-wipe path instartFrame()are deliberately not covered.
I'd appreciate it if owners of the other GxEPDDisplay boards could try the branch before this goes further.
Safety considerations / open questions
- Re-entrancy. The callback runs inside
ui_task.loop(). I checked the listener callbacks the mesh can invoke ondev:onMessageRecv,onChannelMessageRecv,onQueueSizeChanged,onDiscoveredContact,onControlDataRecv, andnotify. They only update state, change the current screen and set_next_refresh.turnOn()is only called when the display is off, andendFrame()only refreshes when it is on. So nothing touches the display during a refresh. A static guard prevents nested hook calls. This is an invariant future changes would need to keep, though. Is that acceptable to you? - Shared SPI bus. GxEPD2 waits for BUSY outside any SPI transaction: each
_writeCommand/_writeDatabegins and ends its own transaction. So radio SPI traffic from the hook should be safe even on boards where radio and display share a bus. On the T-Echo they are on separate buses (SPIandSPI1), so I could not test the shared case. - Scope. Only the partial refresh in
endFrame()is covered.begin(),turnOff()/powerOff()and the screen-wipe path instartFrame()(clearScreen/writeScreenBuffer) are left blocking on purpose. - Should the repeater and room server firmwares get the same treatment? Their loops are simpler.
If the approach looks acceptable, I can open a PR from the branch above.
The investigation, the reference implementation and this write-up were done with an AI coding assistant (Claude Code), and tested on my device as described above. Please review with that in mind.
Activity
fdlamotte commentedon Oct 27, 2026
That's something I've wanted from the first time I messed with gxepd lib ...
And I thought the only option was to bring in the threads (which changes things from the current loop model), but your proposal is interesting
I will probably make some tests with this to see how it behaves ...
It would be great to find a subset of commands that always run and call the inner loop from the main loop (so we don't go into inconsistencies as code evolves). What do you think about that @ripplebiz ?
Please wait for #3687 to be merged (maybe you can give feedback on this one ? I'm waiting for confirmation this is working as expected to merge it myself or I wait for any other dev to merge ;) ...)