From 4bd4a77989daccb51279c0244cf47858cdc0b177 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 8 Sep 2026 12:35:37 -0700 Subject: [PATCH] Reset RX recovery deadline after successful transmissions --- src/Dispatcher.cpp | 9 +- .../test_radio_liveness.cpp | 103 ++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 8b849536e..36156bc31 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -429,10 +429,15 @@ void Dispatcher::loop() { } _radio->onSendFinished(); + // RX can occur entirely between two loop observations during a TX + // burst. Start a fresh RX recovery allowance after each successful TX; + // otherwise a later CAD pause can trip the eight-second watchdog using + // the start time of the whole healthy burst. + radio_nonrx_start = _ms->getMillis(); #ifdef RADIO_LIVENESS_SOFT_ONLY - last_radio_activity_ms = _ms->getMillis(); // TX success -> radio is alive + last_radio_activity_ms = radio_nonrx_start; // TX success -> radio is alive #else - radio_liveness.noteActivity(_ms->getMillis()); // TX success -> radio is alive + radio_liveness.noteActivity(radio_nonrx_start); // TX success -> radio is alive #endif restoreOutboundTxOverrides(); logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); diff --git a/test/test_radio_liveness/test_radio_liveness.cpp b/test/test_radio_liveness/test_radio_liveness.cpp index 9140b9bad..44621e416 100644 --- a/test/test_radio_liveness/test_radio_liveness.cpp +++ b/test/test_radio_liveness/test_radio_liveness.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include using mesh::RadioLivenessTracker; using mesh::RadioRecoveryAction; @@ -63,6 +65,107 @@ TEST(LR2021Band, SelectsTheCorrectFrontEnd) { EXPECT_TRUE(mesh::lr2021::isHighBand(2400.0f)); } +namespace { +class AdvancingClock : public mesh::MillisecondClock { +public: + unsigned long now = 1; + unsigned long getMillis() override { return now++; } +}; + +class BurstRadio : public mesh::Radio { +public: + bool receiving = true; + bool cad_busy = false; + bool broken_rx = false; + int completed = 0; + int recoveries = 0; + int recvRaw(uint8_t*, int) override { + if (!broken_rx) receiving = true; + return 0; + } + uint32_t getEstAirtimeFor(int) override { return 1000; } + float packetScore(float, int) override { return 1; } + bool startSendRaw(const uint8_t*, int) override { + receiving = false; + return true; + } + bool isSendComplete() override { ++completed; return true; } + void onSendFinished() override { receiving = false; } + bool isInRecvMode() const override { return receiving; } + bool isReceiving() override { + // Active CAD may leave the radio outside RX until the next recvRaw(). + if (cad_busy) receiving = false; + return cad_busy; + } + bool recoverRadio(bool) override { + ++recoveries; + receiving = !broken_rx; + return !broken_rx; + } +}; + +class WatchdogDispatcher : public mesh::Dispatcher { +public: + WatchdogDispatcher(BurstRadio& radio, AdvancingClock& clock, + StaticPoolPacketManager& packets) + : Dispatcher(radio, clock, packets) {} + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; } + uint16_t errors() const { return _err_flags; } +}; +} + +TEST(DispatcherRadioWatchdog, SuccessfulTxBurstDoesNotTriggerStuckRxRecovery) { + AdvancingClock clock; + BurstRadio radio; + StaticPoolPacketManager packets(16); + WatchdogDispatcher dispatcher(radio, clock, packets); + dispatcher.begin(); + for (int i = 0; i < 12; ++i) { + auto* packet = packets.allocNew(); + ASSERT_NE(packet, nullptr); + packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << 2); + packet->path_len = 0; + packet->payload_len = 1; + packet->payload[0] = 0; + ASSERT_TRUE(dispatcher.sendPacket(packet, 0)); + } + dispatcher.loop(); + // RX happens inside each loop between completed TX and the next TX. The + // watchdog at the top of the loop sees only TX, for longer than 8 seconds. + for (int i = 0; i < 10; ++i) { + clock.now += 1000; + if (i == 9) radio.cad_busy = true; + dispatcher.loop(); + } + ASSERT_EQ(radio.completed, 10); + clock.now += 100; + dispatcher.loop(); + EXPECT_EQ(radio.recoveries, 0); + EXPECT_EQ(dispatcher.errors() & ERR_EVENT_STARTRX_TIMEOUT, 0); + + // A subsequent genuine RX failure still gets its own recovery deadline. + radio.broken_rx = true; + clock.now += 8100; + dispatcher.loop(); + EXPECT_EQ(radio.recoveries, 1); + EXPECT_NE(dispatcher.errors() & ERR_EVENT_STARTRX_TIMEOUT, 0); +} + +TEST(DispatcherRadioWatchdog, StuckReceiverStillRecoversWithoutTransmitActivity) { + AdvancingClock clock; + BurstRadio radio; + StaticPoolPacketManager packets(2); + WatchdogDispatcher dispatcher(radio, clock, packets); + dispatcher.begin(); + radio.broken_rx = true; + radio.receiving = false; + dispatcher.loop(); + clock.now += 8100; + dispatcher.loop(); + EXPECT_EQ(radio.recoveries, 1); + EXPECT_NE(dispatcher.errors() & ERR_EVENT_STARTRX_TIMEOUT, 0); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();