fix(radio): keep the radio serviced while an e-ink refresh blocks the loop

GxEPD2 polls the panel's BUSY pin for the whole refresh (up to ~1-2s),
stalling the main loop. A TX finishing in that window left the SX126x in
standby -- deaf -- until the refresh ended; packets received meanwhile could
be lost (only the latest is readable, and a later CRC failure rejects a good
one still pending). Hook GxEPD2's busy callback to finish TX / drain RX into
a small staging queue and re-arm right away, without touching dispatch/UI.
Wired only where the display driver supports it (GxEPD2: the two e-ink Solo
boards); OLED and sim builds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-24 15:21:38 +02:00
co-authored by Claude Opus 5.5
parent 81cee3846f
commit 22eba3c244
13 changed files with 184 additions and 16 deletions
+13
View File
@@ -15,6 +15,16 @@
static bool g_sim_ready = false; static bool g_sim_ready = false;
#endif #endif
#ifdef DISPLAY_HAS_BUSY_PUMP
// Run from the e-ink driver's BUSY-pin wait (DisplayDriver::setBusyPumpFn(),
// wired in setup() below) so packets landing during a refresh are pulled off
// the radio as they arrive, and a TX that finishes mid-refresh goes straight
// back to listening -- see RadioLibWrapper::pumpRecvDuringBlockingWait().
static void pumpRadioDuringDisplayBusyWait(void*) {
radio_driver.pumpRecvDuringBlockingWait();
}
#endif
// Believe it or not, this std C function is busted on some platforms! // Believe it or not, this std C function is busted on some platforms!
static uint32_t _atoi(const char* sp) { static uint32_t _atoi(const char* sp) {
uint32_t n = 0; uint32_t n = 0;
@@ -293,6 +303,9 @@ void setup() {
if (disp && the_mesh.getNodePrefs()) if (disp && the_mesh.getNodePrefs())
disp->setBrightness(the_mesh.getNodePrefs()->display_brightness); disp->setBrightness(the_mesh.getNodePrefs()->display_brightness);
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
#ifdef DISPLAY_HAS_BUSY_PUMP
if (disp) disp->setBusyPumpFn(pumpRadioDuringDisplayBusyWait, nullptr);
#endif
#endif #endif
#ifdef NRF52_PLATFORM #ifdef NRF52_PLATFORM
+2 -2
View File
@@ -26,8 +26,8 @@ public:
float getCurrentRSSI() override { float getCurrentRSSI() override {
return ((CustomLLCC68 *)_radio)->getRSSI(false); return ((CustomLLCC68 *)_radio)->getRSSI(false);
} }
float getLastRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); }
float packetScore(float snr, int packet_len) override { float packetScore(float snr, int packet_len) override {
int sf = ((CustomLLCC68 *)_radio)->spreadingFactor; int sf = ((CustomLLCC68 *)_radio)->spreadingFactor;
+2 -2
View File
@@ -38,8 +38,8 @@ public:
_radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts
} }
float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); }
uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); }
+2 -2
View File
@@ -88,8 +88,8 @@ public:
_radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts
} }
float getLastRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); }
uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); } uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); }
+2 -2
View File
@@ -26,8 +26,8 @@ public:
float getCurrentRSSI() override { float getCurrentRSSI() override {
return ((CustomSTM32WLx *)_radio)->getRSSI(false); return ((CustomSTM32WLx *)_radio)->getRSSI(false);
} }
float getLastRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); }
float packetScore(float snr, int packet_len) override { float packetScore(float snr, int packet_len) override {
int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor; int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor;
+2 -2
View File
@@ -42,8 +42,8 @@ public:
float getCurrentRSSI() override { float getCurrentRSSI() override {
return ((CustomSX1262 *)_radio)->getRSSI(false); return ((CustomSX1262 *)_radio)->getRSSI(false);
} }
float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); }
float packetScore(float snr, int packet_len) override { float packetScore(float snr, int packet_len) override {
int sf = ((CustomSX1262 *)_radio)->spreadingFactor; int sf = ((CustomSX1262 *)_radio)->spreadingFactor;
+2 -2
View File
@@ -29,8 +29,8 @@ public:
float getCurrentRSSI() override { float getCurrentRSSI() override {
return ((CustomSX1268 *)_radio)->getRSSI(false); return ((CustomSX1268 *)_radio)->getRSSI(false);
} }
float getLastRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); }
float packetScore(float snr, int packet_len) override { float packetScore(float snr, int packet_len) override {
int sf = ((CustomSX1268 *)_radio)->spreadingFactor; int sf = ((CustomSX1268 *)_radio)->spreadingFactor;
+2 -2
View File
@@ -25,8 +25,8 @@ public:
float getCurrentRSSI() override { float getCurrentRSSI() override {
return ((CustomSX1276 *)_radio)->getRSSI(false); return ((CustomSX1276 *)_radio)->getRSSI(false);
} }
float getLastRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); } float readLiveRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); } float readLiveSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); }
float packetScore(float snr, int packet_len) override { float packetScore(float snr, int packet_len) override {
int sf = ((CustomSX1276 *)_radio)->spreadingFactor; int sf = ((CustomSX1276 *)_radio)->spreadingFactor;
+73 -2
View File
@@ -253,6 +253,23 @@ bool RadioLibWrapper::isInRecvMode() const {
} }
int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
if (_pump_count > 0) {
// pumpRecvDuringBlockingWait() already pulled this off the chip while
// the main loop was stuck (e-ink refresh) -- hand over the staged copy.
// Any packet that has landed since stays flagged and is read live once
// the queue is empty.
uint8_t slot = _pump_head;
int len = _pump_len[slot];
if (len > sz) len = sz;
memcpy(bytes, _pump_data[slot], len);
_pumped_pop_snr = _pump_snr[slot];
_pumped_pop_rssi = _pump_rssi[slot];
_last_recv_was_pumped = true;
_pump_head = (_pump_head + 1) % PUMP_QUEUE_SIZE;
_pump_count--;
return len;
}
int len = 0; int len = 0;
if (state & STATE_INT_READY) { if (state & STATE_INT_READY) {
len = _radio->getPacketLength(); len = _radio->getPacketLength();
@@ -268,6 +285,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
n_recv++; n_recv++;
} }
} }
_last_recv_was_pumped = false;
#if defined(USE_LR2021) #if defined(USE_LR2021)
state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors
#else #else
@@ -281,6 +299,49 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
return len; return len;
} }
// See the class comment in RadioLibWrappers.h. STATE_INT_READY is shared by
// the TX-done and RX-done interrupts, so the base state disambiguates them;
// anything else (mid-TX, idle) is left alone.
void RadioLibWrapper::pumpRecvDuringBlockingWait() {
if (state == (uint8_t)(STATE_TX_WAIT | STATE_INT_READY)) {
// After TX_DONE the chip falls back to standby: deaf until the main loop
// reaches isSendComplete()/onSendFinished(). Do the radio side of
// onSendFinished() now and go straight back to RX; those two then only
// report it (see _tx_done_early).
_radio->finishTransmit();
_board->onAfterTransmit();
n_sent++;
_tx_done_early = true;
state = STATE_IDLE;
armRecv();
return;
}
if (state != (uint8_t)(STATE_RX | STATE_INT_READY)) return;
if (_pump_count >= PUMP_QUEUE_SIZE) return; // staging full -- let the main loop drain it via recvRaw() first
uint8_t slot = (_pump_head + _pump_count) % PUMP_QUEUE_SIZE;
int len = _radio->getPacketLength();
if (len > 0) {
if (len > MAX_TRANS_UNIT) len = MAX_TRANS_UNIT;
int err = _radio->readData(_pump_data[slot], len);
if (err == RADIOLIB_ERR_NONE) {
_pump_len[slot] = (uint8_t)len;
_pump_snr[slot] = readLiveSNR();
_pump_rssi[slot] = readLiveRSSI();
_pump_count++;
n_recv++;
} else {
n_recv_errors++;
}
}
#if defined(USE_LR2021)
state = STATE_RX;
#else
state = STATE_IDLE;
#endif
armRecv();
}
uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) {
return _radio->getTimeOnAir(len_bytes) / 1000; return _radio->getTimeOnAir(len_bytes) / 1000;
} }
@@ -299,6 +360,9 @@ bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) {
} }
bool RadioLibWrapper::isSendComplete() { bool RadioLibWrapper::isSendComplete() {
// Already finished by pumpRecvDuringBlockingWait(): state now belongs to
// the re-armed RX (and may flag a newly received packet), so don't touch it.
if (_tx_done_early) return true;
if (state & STATE_INT_READY) { if (state & STATE_INT_READY) {
state = STATE_IDLE; state = STATE_IDLE;
n_sent++; n_sent++;
@@ -308,6 +372,10 @@ bool RadioLibWrapper::isSendComplete() {
} }
void RadioLibWrapper::onSendFinished() { void RadioLibWrapper::onSendFinished() {
if (_tx_done_early) {
_tx_done_early = false;
return;
}
_radio->finishTransmit(); _radio->finishTransmit();
_board->onAfterTransmit(); _board->onAfterTransmit();
state = STATE_IDLE; state = STATE_IDLE;
@@ -336,10 +404,13 @@ bool RadioLibWrapper::isChannelActive() {
} }
float RadioLibWrapper::getLastRSSI() const { float RadioLibWrapper::getLastRSSI() const {
return _radio->getRSSI(); // A packet handed back via the pump-queue path (recvRaw()) needs the
// reading captured when it was pulled off the chip, not whatever's newest
// in the registers now -- see the field comments in RadioLibWrappers.h.
return _last_recv_was_pumped ? _pumped_pop_rssi : readLiveRSSI();
} }
float RadioLibWrapper::getLastSNR() const { float RadioLibWrapper::getLastSNR() const {
return _radio->getSNR(); return _last_recv_was_pumped ? _pumped_pop_snr : readLiveSNR();
} }
float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) {
+47
View File
@@ -89,6 +89,47 @@ protected:
// firmware defaults (frequency/bandwidth/SF/CR/TX power/preamble/gain). // firmware defaults (frequency/bandwidth/SF/CR/TX power/preamble/gain).
virtual bool radioHardReset() { return false; } virtual bool radioHardReset() { return false; }
// --- Busy-wait packet pump (see pumpRecvDuringBlockingWait() below) ---
// A display driver's e-ink busy-wait can block the main loop for over a
// second per refresh (GxEPD2 setBusyCallback()). The radio stays in
// continuous RX meanwhile, but only the latest packet is readable: the
// chip reports a single length/offset, so if a second packet completes
// before the loop gets back to recvRaw(), the first is lost -- and IRQ
// flags are sticky, so a later CRC-failed packet makes readData() reject
// a good one still waiting. pumpRecvDuringBlockingWait() pulls each packet
// into this small staging queue as it lands, deliberately WITHOUT touching
// packet parsing/dispatch (Dispatcher::checkRecv() does that later, back
// on the main loop) -- reaching into routing/UI from here would reenter
// the very display code that's blocked calling us.
// A TX finishing mid-refresh is worse: after TX_DONE the chip falls back to
// standby and hears nothing at all until re-armed -- so the pump also
// finishes the TX and resumes RX right away.
static const uint8_t PUMP_QUEUE_SIZE = 2;
uint8_t _pump_data[PUMP_QUEUE_SIZE][MAX_TRANS_UNIT];
uint8_t _pump_len[PUMP_QUEUE_SIZE];
float _pump_snr[PUMP_QUEUE_SIZE];
float _pump_rssi[PUMP_QUEUE_SIZE];
uint8_t _pump_count = 0, _pump_head = 0;
// Set whenever recvRaw() hands back a packet that pumpRecvDuringBlockingWait()
// already pulled off the chip, so getLastRSSI()/getLastSNR() (below) report
// the reading captured at THAT read -- not whatever's newest in the chip's
// registers if another packet has landed (live or pumped) since.
bool _last_recv_was_pumped = false;
float _pumped_pop_snr = 0, _pumped_pop_rssi = 0;
// The pump already did onSendFinished()'s radio work and re-armed RX, so
// isSendComplete() reports done and onSendFinished() skips the radio part
// (it would drop the chip back to standby and clobber a pending RX flag).
// A subclass onSendFinished() extension then runs with RX already armed.
bool _tx_done_early = false;
// Chip-specific SNR/RSSI register reads. Implemented per radio type
// (renamed from what used to be each subclass's getLastRSSI()/getLastSNR()
// override) so the base class can route getLastRSSI()/getLastSNR() through
// the pumped-packet cache above instead of every subclass needing to know
// about it.
virtual float readLiveRSSI() const = 0;
virtual float readLiveSNR() const = 0;
public: public:
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; }
@@ -144,6 +185,12 @@ public:
void loop() override; void loop() override;
// Called only from a display driver's busy-wait callback (see
// DisplayDriver::setBusyPumpFn()) while the main loop is blocked -- see
// the class comment above. Never call this from anywhere that could end
// up back inside display/UI code.
void pumpRecvDuringBlockingWait();
uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecv() const { return n_recv; }
uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; }
uint32_t getPacketsSent() const { return n_sent; } uint32_t getPacketsSent() const { return n_sent; }
+16
View File
@@ -30,6 +30,14 @@ protected:
unsigned long _marquee_next_at = 0; unsigned long _marquee_next_at = 0;
DisplayDriver(int w, int h) { _w = w; _h = h; } DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; } void setDimensions(int w, int h) { _w = w; _h = h; }
// Registered via setBusyPumpFn() below; drivers whose underlying vendor
// library exposes a hook for its blocking hardware wait (e.g. GxEPD2's
// setBusyCallback()) call this from it. Default no-op, so any driver/board
// that never wires one up behaves exactly as before.
void (*_busy_pump_fn)(void*) = nullptr;
void* _busy_pump_ctx = nullptr;
void callBusyPump() { if (_busy_pump_fn) _busy_pump_fn(_busy_pump_ctx); }
public: public:
enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light
@@ -499,6 +507,14 @@ public:
virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never) virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never)
virtual void endFrame() = 0; virtual void endFrame() = 0;
// Called from board setup to run safe background work during a blocking
// hardware wait (currently: an e-ink panel's BUSY-pin poll, which can run
// for over a second on a full refresh and would otherwise stall the whole
// main loop -- see RadioLibWrapper::pumpRecvDuringBlockingWait(), the
// motivating use). fn must not touch this display or any UI state: it can
// run reentrantly, nested inside the very call it's servicing.
void setBusyPumpFn(void (*fn)(void*), void* ctx) { _busy_pump_fn = fn; _busy_pump_ctx = ctx; }
#ifdef ENABLE_SCREENSHOT #ifdef ENABLE_SCREENSHOT
// Screenshot support — return raw framebuffer and its size in bytes. // Screenshot support — return raw framebuffer and its size in bytes.
// 0=OLED (page-based, column-major), 1=e-ink (row-major, MSB-first, 1=white/0=black). // 0=OLED (page-based, column-major), 1=e-ink (row-major, MSB-first, 1=white/0=black).
+4
View File
@@ -84,6 +84,10 @@ bool GxEPDDisplay::begin() {
SPI1.begin(); SPI1.begin();
#endif #endif
display.init(115200, true, 2, false); display.init(115200, true, 2, false);
// Runs ~every 1ms while _waitWhileBusy() polls the panel's BUSY pin, i.e.
// for as long as a refresh blocks the main loop. See busyCallbackTrampoline
// and callBusyPump()/setBusyPumpFn() (DisplayDriver.h).
display.epd2.setBusyCallback(busyCallbackTrampoline, this);
display.setRotation(DISPLAY_ROTATION); display.setRotation(DISPLAY_ROTATION);
setTextSize(1); setTextSize(1);
display.setPartialWindow(0, 0, display.width(), display.height()); display.setPartialWindow(0, 0, display.width(), display.height());
+17
View File
@@ -24,6 +24,11 @@
#include "DisplayDriver.h" #include "DisplayDriver.h"
#include "MiscFixedFont.h" #include "MiscFixedFont.h"
// This driver calls callBusyPump() during its BUSY-pin waits; app code keys
// its setBusyPumpFn() wiring off this so it isn't compiled for displays (or
// the sim, whose radio isn't a RadioLibWrapper) that never would.
#define DISPLAY_HAS_BUSY_PUMP 1
#ifndef DISPLAY_ROTATION #ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 0 #define DISPLAY_ROTATION 0
#endif #endif
@@ -68,6 +73,18 @@ class GxEPDDisplay : public DisplayDriver {
uint8_t glyphXAdvance(uint32_t cp, int sc); uint8_t glyphXAdvance(uint32_t cp, int sc);
int scale() const { return (width() >= height()) ? 2 : 1; } int scale() const { return (width() >= height()) ? 2 : 1; }
// GxEPD2_EPD::setBusyCallback() wants a plain function pointer with a
// void* param, not a member function -- this trampolines back into the
// instance so callBusyPump() (DisplayDriver.h) can reach whatever board
// setup registered via setBusyPumpFn(). _waitWhileBusy() calls the callback
// *instead of* its own delay(1), so keep that delay here: without it the
// wait becomes a hard spin that starves the RTOS idle task (no CPU sleep)
// and equal-priority tasks for the whole refresh.
static void busyCallbackTrampoline(const void* param) {
((GxEPDDisplay*)param)->callBusyPump();
delay(1);
}
public: public:
#if defined(EINK_DISPLAY_MODEL) #if defined(EINK_DISPLAY_MODEL)
GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H), GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H),