diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 28305ca262..57df6e8554 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -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 */ diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 852e0fef6f..14e016b9ea 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -355,5 +355,17 @@ class RadioInterface std::unique_ptr 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); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 40b446c3fb..c0a56a7f66 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -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 diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 402f018f11..b683a4e810 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -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. diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 543d3f92ae..423c1248e0 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -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(StaleRxFlagAction::Keep), static_cast(staleRxFlagAction(false, 0, 99))); + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Keep), static_cast(staleRxFlagAction(true, 98, 99))); +} + +static void test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket() +{ + // Inclusive at the boundary, matching Throttle::hasElapsed(). + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::ClearPreamble), static_cast(staleRxFlagAction(false, 99, 99))); + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Rearm), static_cast(staleRxFlagAction(true, 99, 99))); +} + +static void test_staleRxFlagAction_barePreambleIsOnlyCleared() +{ + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::ClearPreamble), static_cast(staleRxFlagAction(false, 2200, 2115))); +} + +static void test_staleRxFlagAction_staleHeaderIsRearmed() +{ + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Rearm), static_cast(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()); }