Skip to content

[Feature request] Keep mesh/BLE serviced while the e-ink display refreshes #3701

Open

Description

@akalinin-softvelum

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 in startFrame() 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

  1. Re-entrancy. The callback runs inside ui_task.loop(). I checked the listener callbacks the mesh can invoke on dev: onMessageRecv, onChannelMessageRecv, onQueueSizeChanged, onDiscoveredContact, onControlDataRecv, and notify. They only update state, change the current screen and set _next_refresh. turnOn() is only called when the display is off, and endFrame() 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?
  2. Shared SPI bus. GxEPD2 waits for BUSY outside any SPI transaction: each _writeCommand/_writeData begins 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 (SPI and SPI1), so I could not test the shared case.
  3. Scope. Only the partial refresh in endFrame() is covered. begin(), turnOff()/powerOff() and the screen-wipe path in startFrame() (clearScreen/writeScreenBuffer) are left blocking on purpose.
  4. 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

  1. fdlamotte commented on Oct 27, 2026

    @fdlamotte
    Collaborator

    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 ;) ...)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions