LoRa LBT: retire RX flags that plain RX never completes

Plain RX runs with no chip timeout (startReceiveDutyCycleAuto(16, 8)
falls back to RX_TIMEOUT_INF) and the DIO mask carries RX_DONE only, so
a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes
stays latched until something re-arms RX. On a node with nothing to
send, nothing does. The CAD->RX handoff already has this recovery
(cadTimeout plus checkCadHandoffTimeout()); plain RX had none.

checkStaleRxFlags() is its twin on the ~1 s pollMissedIrqs() tick. It
notes when flags are first seen, and if no RX_DONE has consumed them one
max-length packet later:

- a header (valid or errored) re-arms RX. RX_DONE clears every flag in
  readData(), so a header still latched has no frame behind it. SX1280
  DS rev 3.3 section 16.2: a header error leaves RX wedged with no
  RX_DONE or timeout, and only a re-arm recovers it.
- a bare preamble is only cleared. ClearIrqStatus/ClearIrq changes the
  IRQ register alone on SX126x, SX1280, LR11x0 and LR20x0, so it never
  aborts a reception; startReceive() goes through standby and would.

HEADER_VALID is never cleared on its own: RadioLib's readData() reads
it at RX_DONE, and on LR2021 its absence reports the packet as header
damaged.

Skipped while a CAD handoff is outstanding (its own timeout owns that
RX) and while RX_DONE is pending. RF95 opts out: its carrier sense reads
modem status rather than latched flags, and SX127x maps neither
PREAMBLE_DETECTED nor HEADER_ERR.

The decision is staleRxFlagAction() in RadioInterface.h, pinned in
test_radio at both sides of the window and for each action.
This commit is contained in:
nomdetom committed 2026-09-24 21:23:44 +01:00
1 parent ffd30f5973
commit 45262f8257
5 files changed
+99

No files matched your search

+3
View File
@@ -60,6 +60,9 @@ class RF95Interface : public RadioLibInterface
/** are we actively receiving a packet (only called during receiving state) */
virtual bool isActivelyReceiving() override;
// Carrier sense here reads modem status, not latched flags, and SX127x maps no PREAMBLE or HEADER_ERR.
void checkStaleRxFlags() override {}
/**
* Start waiting to receive a message
*/
+12
View File
@@ -355,5 +355,17 @@ class RadioInterface
std::unique_ptr<RadioInterface> initLoRa();
// What the plain-RX poll does with PREAMBLE/HEADER flags that no RX_DONE has consumed.
enum class StaleRxFlagAction : uint8_t { Keep, ClearPreamble, Rearm };
// RX_DONE clears every flag, so one still latched a max packet after first sight has no frame behind it.
// A header may mean a wedged demodulator (SX1280 DS 16.2), so re-arm; a bare preamble is only cleared.
constexpr StaleRxFlagAction staleRxFlagAction(bool headerSeen, uint32_t sinceFirstSeenMsec, uint32_t maxPacketTimeMsec)
{
return sinceFirstSeenMsec < maxPacketTimeMsec ? StaleRxFlagAction::Keep
: headerSeen ? StaleRxFlagAction::Rearm
: StaleRxFlagAction::ClearPreamble;
}
/// Debug printing for packets
void printPacket(const char *prefix, const meshtastic_MeshPacket *p);
+39
View File
@@ -739,6 +739,7 @@ void RadioLibInterface::startReceive()
// This is the sole place the recovery ladder is cleared - nothing short of an armed RX counts as fixed.
rxOffline = false;
chipRecoveryFailures = 0;
rxFlagsSeenMs = 0;
powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn);
}
@@ -748,6 +749,7 @@ void RadioLibInterface::pollMissedIrqs()
if (isReceiving) {
checkRxDoneIrqFlag();
checkCadHandoffTimeout();
checkStaleRxFlags();
}
if (sendingPacket) {
checkTxDoneIrqFlag();
@@ -798,6 +800,43 @@ void RadioLibInterface::checkCadHandoffTimeout()
}
}
void RadioLibInterface::checkStaleRxFlags()
{
// A handoff RX has its own timeout, and a pending RX_DONE is about to clear every flag itself.
if (cadHandoffRxStart)
return;
const uint32_t irq = iface->getIrqFlags();
if (irq & iface->getIrqMapped(1UL << RADIOLIB_IRQ_RX_DONE))
return;
// HEADER_ERR counts: on SX1280 it leaves RX wedged with no RX_DONE or TIMEOUT to follow.
const bool headerSeen = irq & iface->getIrqMapped((1UL << RADIOLIB_IRQ_HEADER_VALID) | (1UL << RADIOLIB_IRQ_HEADER_ERR));
const bool preambleSeen = irq & iface->getIrqMapped(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED);
if (!headerSeen && !preambleSeen) {
rxFlagsSeenMs = 0;
return;
}
if (!rxFlagsSeenMs) {
rxFlagsSeenMs = Time::skipZero(Time::getMillis());
return;
}
const uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader));
switch (staleRxFlagAction(headerSeen, Time::getMillis() - rxFlagsSeenMs, maxPacketTimeMsec)) {
case StaleRxFlagAction::Keep:
break;
case StaleRxFlagAction::Rearm:
LOG_DEBUG("RX header stale, re-arm");
startReceive(); // clears rxFlagsSeenMs
break;
case StaleRxFlagAction::ClearPreamble:
// A clear never aborts a reception, unlike the standby inside startReceive().
LOG_DEBUG("RX preamble stale, cleared");
iface->clearIrq(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED);
rxFlagsSeenMs = 0;
break;
}
}
void RadioLibInterface::periodicRadioMaintenance()
{
// Every startReceive() call site is event-driven (RX/TX ISR, the CAD-busy branch, reconfigure), and a
+6
View File
@@ -200,6 +200,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
/** Re-arm if a CAD->RX handoff has produced no packet well past one max-length airtime. */
void checkCadHandoffTimeout();
// Time::getMillis() when plain RX was first seen holding PREAMBLE/HEADER flags, or 0 if none.
uint32_t rxFlagsSeenMs = 0;
/** Plain-RX twin of checkCadHandoffTimeout(): retire flags no RX_DONE consumed within a max packet. */
virtual void checkStaleRxFlags();
/**
* Reset AGC by power-cycling the analog frontend.
* Subclasses override with chip-specific calibration sequences.
+39
View File
@@ -648,6 +648,41 @@ void tearDown(void)
mockMeshService = nullptr;
}
// staleRxFlagAction() in src/mesh/RadioInterface.h: the decision behind RadioLibInterface::checkStaleRxFlags(),
// the plain-RX twin of checkCadHandoffTimeout(). Plain RX runs with no chip timeout, and the DIO mask carries
// RX_DONE only, so a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes stays latched until
// something re-arms RX - on an idle node, never. RX_DONE clears every flag in readData(), so a flag still
// latched one max packet after it was first seen has no frame behind it.
//
// Pinned: nothing happens inside that window (a frame may still be arriving); after it, a bare preamble is
// only cleared, because a clear never aborts a reception; a header re-arms, because SX1280 DS rev 3.3 16.2
// leaves RX wedged after a header error with no RX_DONE or timeout to follow, and a clear does not unwedge it.
// Regressions guarded: re-arming on a preamble (startReceive() goes through standby, which aborts a frame
// that is in fact arriving), acting before the window closes, or never acting at all.
static void test_staleRxFlagAction_keepsFlagsInsideTheWindow()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Keep), static_cast<int>(staleRxFlagAction(false, 0, 99)));
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Keep), static_cast<int>(staleRxFlagAction(true, 98, 99)));
}
static void test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket()
{
// Inclusive at the boundary, matching Throttle::hasElapsed().
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::ClearPreamble), static_cast<int>(staleRxFlagAction(false, 99, 99)));
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Rearm), static_cast<int>(staleRxFlagAction(true, 99, 99)));
}
static void test_staleRxFlagAction_barePreambleIsOnlyCleared()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::ClearPreamble), static_cast<int>(staleRxFlagAction(false, 2200, 2115)));
}
static void test_staleRxFlagAction_staleHeaderIsRearmed()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Rearm), static_cast<int>(staleRxFlagAction(true, 60000, 2115)));
}
void setup()
{
delay(10);
@@ -685,6 +720,10 @@ void setup()
RUN_TEST(test_computePacketTime_reportsNoAirtimeWhenNothingCanBeComputed);
RUN_TEST(test_computePacketTime_rxUsesHeaderInfoAndIsGuarded);
RUN_TEST(test_isRadioLibTimeError_separatesCodesFromDurations);
RUN_TEST(test_staleRxFlagAction_keepsFlagsInsideTheWindow);
RUN_TEST(test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket);
RUN_TEST(test_staleRxFlagAction_barePreambleIsOnlyCleared);
RUN_TEST(test_staleRxFlagAction_staleHeaderIsRearmed);
exit(UNITY_END());
}