diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 57dfb18c92..018ced693f 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -505,19 +505,75 @@ template void LR11x0Interface::startReceive() /** Is the channel currently active? */ template bool LR11x0Interface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, - .detPeak = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, + // check if we can detect a LoRa preamble on the current channel. + // symNum is SetCadParams SymbolNum - a plain count - so take it straight from getCadSymbolCount(), + // which follows the band (8 on 2.4 GHz, as SX1280 scans) and is what sizes the CW slot. + const uint8_t symNum = getCadSymbolCount(); + // detPeak: Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters[symbols][BW][SF5..SF12], + // its measured best CAD detection rates. RadioLib's default is this table's [2 symbols][BW250] row. + // 50 = SWSD003's CAD_DETECT_PEAK fallback, used where it has no measured value. + static constexpr uint8_t CAD_DET_PEAK[4][4][8] = { + // Each block is one symbol count. Within a block the 4 rows are BW 62.5 / 125 / 250 / 500 kHz, + // and the 8 columns are SF5..SF12. + // 2 symbols: + {{39, 45, 47, 53, 59, 61, 64, 63}, + {44, 51, 49, 55, 56, 60, 62, 68}, + {48, 48, 50, 55, 55, 59, 61, 65}, + {76, 80, 71, 77, 69, 50, 50, 50}}, // SF10-12: SWSD003 has no measurement, 50 is its fallback + // 4 symbols: + {{43, 45, 45, 50, 53, 57, 59, 63}, + {44, 46, 49, 53, 53, 55, 57, 62}, + {45, 47, 47, 51, 51, 56, 59, 62}, + {58, 66, 58, 65, 62, 55, 60, 57}}, + // 8 symbols: + {{43, 44, 46, 48, 51, 53, 56, 59}, + {45, 43, 44, 50, 52, 55, 56, 61}, + {42, 44, 45, 48, 50, 53, 55, 60}, + {49, 52, 50, 59, 56, 57, 57, 60}}, + // 16 symbols: + {{41, 44, 43, 46, 49, 52, 54, 60}, + {42, 42, 43, 48, 49, 53, 55, 59}, + {41, 42, 43, 48, 48, 53, 54, 58}, + {44, 47, 45, 53, 52, 53, 57, 62}}}; + // SWSD003 characterises symbol counts 2/4/8/16 against the sub-GHz LoRa modem's four bandwidths. + // Use exact matches to avoid mixing widelora (406.25/812.5/1625 kHz) + // Anything unmatched uses RadioLib default. + // Whole sub-GHz set today; a narrower BW or a symbol count outside 2/4/8/16 needs a row adding. + static constexpr float TABLE_BW_KHZ[4] = {62.5f, 125.0f, 250.0f, 500.0f}; + const int symIdx = symNum == 2 ? 0 : symNum == 4 ? 1 : symNum == 8 ? 2 : symNum == 16 ? 3 : -1; + int bwIdx = -1; + for (int i = 0; i < 4; i++) { + if (bw > TABLE_BW_KHZ[i] - 1.0f && bw < TABLE_BW_KHZ[i] + 1.0f) + bwIdx = i; + } + const uint8_t detPeak = (symIdx < 0 || bwIdx < 0) ? (uint8_t)RADIOLIB_LR11X0_CAD_PARAM_DEFAULT + : CAD_DET_PEAK[symIdx][bwIdx][(sf >= 5 && sf <= 12) ? sf - 5 : 6]; + // Keep preamble/header off the pin - they would fire the ISR mid-frame. Same set the normal RX path + // already programs (stageMode sends irqFlags & irqMask), so isActivelyReceiving() sees no change. + const uint32_t cadIrqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR); + // UM Table 8-8: the timeout bounds the RX that follows a detection, so use one max-length airtime. + // RadioLib scales it by 30.52 us against a real 31.25, landing ~2% long - not worth pre-compensating. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + ChannelScanConfig_t cfg = {.cad = {.symNum = symNum, + .detPeak = detPeak, + // PARAM_DEFAULT lands on 10 in RadioLib, which is SWSD003's CAD_DETECT_MIN .detMin = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = RADIOLIB_LR11X0_CAD_EXIT_MODE_RX, + .timeout = cadRxTimeoutUsec, + .irqFlags = cadIrqFlags, + .irqMask = cadIrqFlags}}; // ignored: startChannelScan() sends irqFlags twice int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX. Drop the latched CAD verdict so the pin releases and the coming + // RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_LR11X0_IRQ_CAD_DONE | RADIOLIB_LR11X0_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } if (result != RADIOLIB_ERR_WRONG_MODEM) return false; } diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 95c6cb1b1f..2042bbd11f 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -79,6 +79,11 @@ template class LR11x0Interface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // LR1120/LR1121 work in both bands. 4 sub-GHz is the SWSD003 table's row we use; 8 on 2.4 GHz + // matches SX1280, the other part sharing that band, so one mesh keeps one CW slot. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + uint8_t getCadSymbolCountWideLora() const override { return 8; } + private: /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ int16_t programModemParams(); diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 2fef848d07..8c11866a54 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -636,19 +636,60 @@ template void LR20x0Interface::startReceive() /** Is the channel currently active? */ template bool LR20x0Interface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, - .detPeak = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + // check if we can detect a LoRa preamble on the current channel. + // symNum is SetLoraCadParams nb_symbols - a plain count - so take it straight from + // getCadSymbolCount(), which follows the band (8 on 2.4 GHz, as SX1280 scans) and sizes the CW slot. + // det_peak is the peak-to-average ratio threshold: a higher value demands a stronger correlation + // peak and so detects less readily, which is why the recommended value falls as the scan lengthens. + // Indexed by that same count, so the threshold tracks the scan instead of drifting out of step. + // Table 6-19 stops at 4 symbols, so a longer scan reuses that row - the last measured point, and + // erring high, i.e. slightly less sensitive than a true 8-symbol value would be. + static constexpr uint8_t CAD_DET_PEAK[4][8] = { + // LR20xx datasheet rev 2.2, Table 6-19 - recommended det_peak, SF5..SF12 + {60, 60, 60, 64, 64, 66, 70, 74}, // 1 symbol + {56, 56, 56, 58, 58, 60, 64, 68}, // 2 symbols (RadioLib's default row) + {51, 51, 52, 54, 56, 60, 60, 65}, // 3 symbols + {51, 51, 51, 54, 56, 60, 60, 64}, // 4 symbols + }; + const uint8_t symNum = getCadSymbolCount(); + const uint8_t detPeak = CAD_DET_PEAK[(symNum >= 1 && symNum <= 4) ? symNum - 1 : 3] + [(sf >= 5 && sf <= 12) ? sf - 5 : 6]; // out of range: 4 symbols, SF11 + // Times the RX that follows a detection. lr20xx_driver calls this field PLL steps of 31.25 us (the + // datasheet's "32 MHz periods" disagrees); RadioLib scales it as 30.52, so we land ~2% long. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + // SetLoraCadParams exit mode: 0x00 CAD only, 0x01 RX on detection, 0x10 TX when clear, as on LR11x0. RadioLib's + // RADIOLIB_LR2021_CAD_EXIT_MODE_RX is 0x02, which the chip refuses with a processing error (-706), so no scan runs. + static constexpr uint8_t CAD_EXIT_MODE_RX = 0x01; + ChannelScanConfig_t cfg = {.cad = {.symNum = symNum, + .detPeak = detPeak, + // ignored: SetLoraCadParams has no det_min - that byte carries + // pnr_delta, which scanChannel() takes from lora.fastCad below .detMin = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = CAD_EXIT_MODE_RX, + .timeout = cadRxTimeoutUsec, + // DS rev 2.2 6.8.3: only routes IRQs to a pin, so keep + // preamble/header off it - they would fire the ISR mid-frame + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR), + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; // ignored: startChannelScan() drops it + // fastCad is not in ChannelScanConfig_t - scanChannel() reads it off the radio object - so pin it. + // false is pnr_delta 0: the scan runs the full nb_symbols, which is what the slot time assumes. + lora.fastCad = false; + int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX. Drop the latched CAD verdict so the pin releases and the coming + // RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_LR2021_IRQ_CAD_DONE | RADIOLIB_LR2021_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } + if (result != RADIOLIB_CHANNEL_FREE && result != RADIOLIB_ERR_WRONG_MODEM) + LOG_WARN("LR20x0 channel scan failed %s%d, reported clear", radioLibErr, result); if (result != RADIOLIB_ERR_WRONG_MODEM) return false; } diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 1372e47426..5b5802eea1 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -83,6 +83,11 @@ template class LR20x0Interface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // LR2021 works in both bands. 4 sub-GHz is Table 6-19's row we use; 8 on 2.4 GHz matches SX1280, + // the other part sharing that band, so one mesh keeps one CW slot. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + uint8_t getCadSymbolCountWideLora() const override { return 8; } + private: /** Chip-side re-init shared by the band-hop and recovery paths: front-end GPIOs, begin(), CRC, RF switch, RX gain */ bool fullBegin(float freq); diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 4536dbd502..57df6e8554 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -53,9 +53,16 @@ class RF95Interface : public RadioLibInterface /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; + // Sub-GHz only, CAD fixed in hardware at ~2.25 symbols; the legacy scanChannel() takes no symNum. + // 2 puts the slot on its 2.5-symbol floor, which is the closest the shared formula gets. + uint8_t getCadSymbolCountSubGhz() const override { return 2; } + /** 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.cpp b/src/mesh/RadioInterface.cpp index 4ce58f4f5f..e840bcc229 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1425,17 +1425,25 @@ void RadioInterface::applyModemConfig() - roundtrip air propagation time (assuming max. 30km between nodes); - Tx/Rx turnaround time (maximum of SX126x and SX127x); - MAC processing time (measured on T-beam) */ +uint8_t RadioInterface::getCadSymbolCount() const +{ + return myRegion->wideLora ? getCadSymbolCountWideLora() : getCadSymbolCountSubGhz(); +} + uint32_t RadioInterface::computeSlotTimeMsec() { float sumPropagationTurnaroundMACTime = 0.2 + 0.4 + 7; // in milliseconds float symbolTime = pow_of_2(sf) / bw; // in milliseconds if (myRegion->wideLora) { - // CAD duration derived from AN1200.22 of SX1280 - return (NUM_SYM_CAD_24GHZ + (2 * sf + 3) / 32) * symbolTime + sumPropagationTurnaroundMACTime; + // SX1280 datasheet rev 3.3: CAD duration = (cadSymbolNum + (2*SF + 3) / 32) * Ts, the trailing + // term being the post-scan processing window. Float division: as ints it truncates to 0 for + // every legal SF. SX1280, LR1120 and LR2021 all run here, so take the count from the driver. + return (getCadSymbolCount() + (2.0f * sf + 3) / 32) * symbolTime + sumPropagationTurnaroundMACTime; } else { - // CAD duration for SX127x is max. 2.25 symbols, for SX126x it is number of symbols + 0.5 symbol - return max(2.25, NUM_SYM_CAD + 0.5) * symbolTime + sumPropagationTurnaroundMACTime; + // CAD duration for SX127x is max. 2.25 symbols, for SX126x it is number of symbols + 0.5 symbol. + // getCadSymbolCount() reports the symbols the scan really runs. + return max(2.25, getCadSymbolCount() + 0.5) * symbolTime + sumPropagationTurnaroundMACTime; } } diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 67870d0ad4..14e016b9ea 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -98,10 +98,15 @@ class RadioInterface uint8_t sf = 9; uint8_t cr = 5; - static constexpr uint8_t NUM_SYM_CAD = - 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 - static constexpr uint8_t NUM_SYM_CAD_24GHZ = - 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 + // Fallback per-band counts for a driver that states nothing of its own. Every driver in the tree + // states its own, and they do NOT all agree: the slot has to match the scan each part really runs, + // so a mesh mixing RF95 (2.25 symbols, fixed in hardware) with SX126x/LR (4) runs two slot lengths. + // Uncited on purpose - no single vendor note covers every part they cover. AN1200.77 backs the + // 2.4 GHz figure for SX1280: a longer window moves P(Detection) away from P(False Detection). + static constexpr uint8_t NUM_SYM_CAD = 4; + static constexpr uint8_t NUM_SYM_CAD_24GHZ = 8; + // Runs during RadioInterface's own construction, so it uses the fallbacks above, never a driver + // override. applyModemConfig() recomputes it before anything reads it, and is authoritative. uint32_t slotTimeMsec = computeSlotTimeMsec(); uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving static constexpr uint16_t preambleLengthDefault = @@ -118,6 +123,15 @@ class RadioInterface uint32_t computeSlotTimeMsec(); + // Symbols this part scans, declared per band: a part that works in both need not scan the same + // length in each, and the parts sharing a band do not all agree. Each driver states its own, and + // must pass a matching symNum - these are what size the CW slot, so a mismatch mis-scales backoff. + virtual uint8_t getCadSymbolCountSubGhz() const { return NUM_SYM_CAD; } + virtual uint8_t getCadSymbolCountWideLora() const { return NUM_SYM_CAD_24GHZ; } + + /** Whichever of the two applies to the band actually in use. */ + uint8_t getCadSymbolCount() const; + /** * A temporary buffer used for sending/receiving packets, sized to hold the biggest buffer we might need * */ @@ -341,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 83fa533d24..13211ab7bd 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -416,18 +416,23 @@ void RadioLibInterface::onNotify(uint32_t notification) { switch (notification) { - case ISR_TX: - handleTransmitInterrupt(); // completeSending() already restored the radio to the home config + case ISR_TX: { + // The chip is deaf in standby until startReceive(), so the airtime log and printPacket() wait until after it. + meshtastic_MeshPacket *sent = handleTransmitInterrupt(); // radio already back on the home config // Let the hooks pre-stage the radio for the NEXT queued packet. Not required for correctness - // TRANSMIT_DELAY_COMPLETED asks again before the scan, which is where the answer is acted on - // but it keeps the post-TX listen window on the channel we are about to transmit on. (void)RadioTxHooks::beforeTransmit(this, txQueue.getFront()); startReceive(); setTransmitDelay(); + finishSentPacket(sent); break; + } case ISR_RX: handleReceiveInterrupt(); - startReceive(); + // Re-arm for the next packet. rearmReceive() avoids a standby where the chip is already in RX, + // so a second packet that is already arriving is not aborted. + rearmReceive(); setTransmitDelay(); break; case ISR_POLL_TICK: @@ -461,12 +466,16 @@ void RadioLibInterface::onNotify(uint32_t notification) } else if (action == RadioTxHook::PRETX_DEFER) { setTransmitDelay(); // the radio config moved, so re-run the delay and scan on it } else { - if (isChannelActive()) { // check if there is currently a LoRa packet on the channel - if (!RadioTxHooks::holdsRadio(txp)) { - startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again - } + // Listen-before-talk: a CAD preamble scan immediately before we key up. + LOG_DEBUG("CAD arm"); + if (isChannelActive()) { // currently traffic on the channel? + LOG_DEBUG("CAD busy"); + // Beacon target or not: reconfigureForBeaconTX() already left RX running on that + // config, so skipping this only ever left the node deaf in standby. + rearmReceive(); setTransmitDelay(); } else { + LOG_DEBUG("CAD free"); // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and // actual transmission as short as possible txp = txQueue.dequeue(); @@ -578,47 +587,58 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ return false; } -void RadioLibInterface::handleTransmitInterrupt() +meshtastic_MeshPacket *RadioLibInterface::handleTransmitInterrupt() { - // This can be null if we forced the device to enter standby mode. In that case - // ignore the transmit interrupt - if (sendingPacket) - completeSending(); + // Null if we forced the device into standby, which already completed the send. + meshtastic_MeshPacket *sent = detachSentPacket(); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); // But our transmitter is definitely off now + return sent; } void RadioLibInterface::completeSending() { - // We are careful to clear sending packet before calling printPacket because - // that can take a long time + finishSentPacket(detachSentPacket()); +} + +meshtastic_MeshPacket *RadioLibInterface::detachSentPacket() +{ + // Cleared first: printPacket() in finishSentPacket() can take a long time. auto p = sendingPacket; sendingPacket = NULL; #ifdef LED_LORA digitalWrite(LED_LORA, LED_STATE_OFF); #endif - - if (p) { - // Packet has been sent, count it toward our TX airtime utilization. - uint32_t xmitMsec = getPacketTime(p); - airTime->logAirtime(TX_LOG, xmitMsec); - - txGood++; - if (!isFromUs(p)) - txRelay++; - printPacket("Completed sending", p); - // Keep this inside `if (p)`: completeSending() also runs on every setStandby(), where a hook - // undoing its own pre-TX switch would recurse back through reconfigure(). + // Keep this behind `if (p)`: completeSending() also runs on every setStandby(), where a hook + // undoing its own pre-TX switch would recurse back through reconfigure(). + if (p) RadioTxHooks::packetReleased(this, p); + return p; +} - // We are done sending that packet, release it - packetPool.release(p); - } +void RadioLibInterface::finishSentPacket(meshtastic_MeshPacket *p) +{ + if (!p) + return; + // Packet has been sent, count it toward our TX airtime utilization. + uint32_t xmitMsec = getPacketTime(p); + airTime->logAirtime(TX_LOG, xmitMsec); + + txGood++; + if (!isFromUs(p)) + txRelay++; + printPacket("Completed sending", p); + + // We are done sending that packet, release it + packetPool.release(p); } void RadioLibInterface::handleReceiveInterrupt() { // when this is called, we should be in receive mode - if we are not, just jump out instead of bombing. Possible Race // Condition? + const bool wasCadHandoff = cadHandoffRxStart != 0; + cadHandoffRxStart = 0; // this RX ends the wait either way; the outcome is logged below + if (!isReceiving) { LOG_ERROR("handleReceiveInterrupt called while not in rx mode"); return; @@ -626,6 +646,17 @@ void RadioLibInterface::handleReceiveInterrupt() isReceiving = false; + // A CAD handoff's RX window expired with nothing on air. There is no packet to read, so don't count + // it as a bad one - the caller's rearmReceive() puts the radio back to listening. + if (iface->checkIrq(RADIOLIB_IRQ_RX_DONE) != 1 && iface->checkIrq(RADIOLIB_IRQ_TIMEOUT) == 1) { + LOG_DEBUG("CAD>RX empty"); + iface->clearIrq(1UL << RADIOLIB_IRQ_TIMEOUT); + return; + } + + if (wasCadHandoff) + LOG_DEBUG("CAD>RX pkt"); + // read the number of actually received bytes size_t length = iface->getPacketLength(); @@ -731,6 +762,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); } @@ -739,6 +771,8 @@ void RadioLibInterface::pollMissedIrqs() // RadioLibInterface::enableInterrupt uses EDGE-TRIGGERED interrupts. Poll as a backup to catch missed edges. if (isReceiving) { checkRxDoneIrqFlag(); + checkCadHandoffTimeout(); + checkStaleRxFlags(); } if (sendingPacket) { checkTxDoneIrqFlag(); @@ -751,6 +785,82 @@ bool RadioLibInterface::resetAGC() return false; } +void RadioLibInterface::noteCadHandoffToRx() +{ + LOG_DEBUG("CAD>RX started"); + cadHandedToRx = true; + // Same clock Throttle compares against, so a native test can drive both across the wrap. 0 is the + // "none outstanding" sentinel and getMillis() does land on it once per wrap, so step past it. + const uint32_t now = Time::getMillis(); + cadHandoffRxStart = now ? now : 1; +} + +void RadioLibInterface::rearmReceive() +{ + // The flag is spent here, so every later call takes the full path - including RX_DONE after a + // handoff, whose bounded RX has already dropped the chip to standby. + if (!cadHandedToRx) { + startReceive(); + return; + } + cadHandedToRx = false; + // Same order the drivers' own startReceive() uses: mark receiving BEFORE arming, or an ISR that + // fires in between reaches handleReceiveInterrupt() while isReceiving is still false and is dropped. + RadioLibInterface::startReceive(); + enableInterrupt(isrRxLevel0); + // The line is not known-low here, and the ISR is rising-edge: catch an RX_DONE that beat the arm. + checkRxDoneIrqFlag(); +} + +void RadioLibInterface::checkCadHandoffTimeout() +{ + // Backstop to the chip's own cadTimeout, which is the primary bound. Doubled so the hardware always + // expires first; this only catches an RX whose timer stopped on a header that never completed. + const uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)); + if (cadHandoffRxStart && Throttle::hasElapsed(cadHandoffRxStart, 2 * maxPacketTimeMsec)) { + LOG_WARN("CAD>RX timeout"); + cadHandoffRxStart = 0; + startReceive(); + } +} + +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 @@ -762,6 +872,9 @@ void RadioLibInterface::periodicRadioMaintenance() startReceive(); return; // a chip just re-inited (or still dead) has no use for an AGC reset this tick } + // resetAGC() ends in startReceive(), whose standby would run a queued packet's TX delay, start it and cut it off. + if (hasQueuedTx()) + return; // A radio that is still decoding packets has gain that isn't stuck const bool hearing = lastRxGoodMs && Throttle::isWithinTimespanMs(lastRxGoodMs, AGC_IDLE_RESET_MS); @@ -824,6 +937,13 @@ void RadioLibInterface::configHardwareForSend() void RadioLibInterface::setStandby() { + // Any handoff is void once the chip leaves RX. Left set, the flag would make the next rearmReceive() + // a no-op on a standby chip - deaf with no recovery - and the window would re-arm over a live packet. + if (cadHandedToRx) // standby between the handoff and the re-arm that adopts it: should not happen + LOG_WARN("CAD>RX void"); + cadHandedToRx = false; + cadHandoffRxStart = 0; + // neither sending nor receiving powerMon->clearState(meshtastic_PowerMon_State_Lora_RXOn); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); @@ -832,6 +952,8 @@ void RadioLibInterface::setStandby() /** start an immediate transmit */ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) { + cadHandoffRxStart = 0; // TX ends any handoff wait; completeSending() re-arms RX itself + /* NOTE: Minimize the actions before startTransmit() to keep the time between channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { @@ -839,6 +961,9 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // Never reaches completeSending(), so any per-packet radio state has to be released here. RadioTxHooks::packetReleased(this, txp); packetPool.release(txp); + // We got here through isChannelActive(), which left the chip in standby for a transmit that is + // no longer happening. Without this the node stays deaf until something else re-arms it. + startReceive(); return false; } else { configHardwareForSend(); // must be after setStandby diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 53153e369f..1daff47dd9 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -194,6 +194,26 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ void pollMissedIrqs(); + // Time::getMillis() at which a CAD->RX handoff left the chip listening without us arming it, or 0 + // if none is outstanding. 0 is a sentinel, so it must be tested before any elapsed comparison. + uint32_t cadHandoffRxStart = 0; + + // True between the handoff and the rearmReceive() that consumes it: the chip is already in RX, so + // that one re-arm must not standby. Both fields are cleared by setStandby(). + bool cadHandedToRx = false; + + /** Record that CAD left the chip in RX: arms both the flag and the no-show window below. */ + void noteCadHandoffToRx(); + + /** 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. @@ -249,7 +269,16 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void startReceive(); - /** can we detect a LoRa preamble on the current channel? */ + /** + * Re-arm RX after a busy-channel CAD detect or after servicing an RX_DONE. Normally a full + * startReceive(); after a CAD->RX handoff the chip is already listening, so that one re-arm + * re-attaches the MCU ISR only - a startReceive() there would standby over the packet CAD found. + */ + void rearmReceive(); + + /** can we detect a LoRa preamble on the current channel? + * A true return means the chip may have been handed to RX in place, so the caller MUST follow it + * with rearmReceive() before anything else touches the radio. */ virtual bool isChannelActive() = 0; /** are we actively receiving a packet (only called during receiving state) @@ -318,7 +347,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ void startTransmitTimerRebroadcast(meshtastic_MeshPacket *p); - void handleTransmitInterrupt(); + /** Detach the sent packet and undo its pre-TX switch; the caller re-arms RX, then finishSentPacket(). */ + meshtastic_MeshPacket *handleTransmitInterrupt(); void handleReceiveInterrupt(); static void timerCallback(void *p1, uint32_t p2); @@ -336,6 +366,9 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; + /** Whether a packet is waiting to transmit; txQueue itself stays private. */ + bool hasQueuedTx() { return !txQueue.empty(); } + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. @@ -358,6 +391,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified * If a send was in progress finish it and return the buffer to the pool */ void completeSending(); + /** Clear sendingPacket and release its per-packet radio state; returns the packet, or null. */ + meshtastic_MeshPacket *detachSentPacket(); + + /** Airtime, counters, log and pool release for a packet detachSentPacket() returned. */ + void finishSentPacket(meshtastic_MeshPacket *p); + /** * Add SNR data to received messages */ diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 3ee23e850b..2d0af23073 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -184,6 +184,13 @@ template bool SX126xInterface::reinitChip() LOG_DEBUG_RADIO("Set DIO2 as %sRF switch, result: %d", dio2AsRfSwitch ? "" : "not ", res); } + if (res == RADIOLIB_ERR_NONE && tcxoVoltage > 0) { + // Standby and TX/RX fallback on STDBY_XOSC keep the TCXO powered: from STDBY_RC every SetRx and SetTx + // first waits out the TCXO start-up delay (5 ms by RadioLib's default). + const int16_t xoscRes = lora.setStandbyXOSC(true); + LOG_DEBUG("Keep TCXO on in standby, result: %d", xoscRes); + } + // If a pin isn't defined, we set it to RADIOLIB_NC, it is safe to always do external RF switching with RADIOLIB_NC as it has // no effect #if ARCH_PORTDUINO @@ -372,11 +379,11 @@ template void SX126xInterface::handleSoftwareLoraIrqPoll() RADIOLIB_SX126X_IRQ_RX_DONE | RADIOLIB_SX126X_IRQ_TIMEOUT | RADIOLIB_SX126X_IRQ_CRC_ERR | RADIOLIB_SX126X_IRQ_HEADER_ERR; const uint16_t noisyRxMask = RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID; - // Do NOT treat a preamble/header-only IRQ as a full RX event: noisy preamble detections would - // repeatedly trigger readData() and starve TX scheduling. Clear these non-terminal bits, or the - // poll loop spins at high rate while they stay latched. - if (!pollTxMode && (irq & noisyRxMask) && ((irq & ~noisyRxMask) == 0U)) { - lora.clearIrqFlags(noisyRxMask); + // A bare PREAMBLE is mid-reception, not an RX event. With a TX queued clear only it, so a noise preamble + // can't block TX; keep HEADER_VALID latched, as it marks a real frame. + const bool preambleOnly = (irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED) && !(irq & RADIOLIB_SX126X_IRQ_HEADER_VALID); + if (!pollTxMode && hasQueuedTx() && preambleOnly && ((irq & ~noisyRxMask) == 0U)) { + lora.clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); scheduleIrqPollTick(); return; } @@ -400,6 +407,12 @@ template int16_t SX126xInterface::trySetStandby() checkNotification(); // handle any pending interrupts before we force standby int16_t err = lora.standby(); + if (err == RADIOLIB_ERR_SPI_CMD_TIMEOUT) { + // After a bounded RX times out, the status byte returned with SET_STANDBY still carries that timeout, and + // RadioLib reports it as a failed command although the chip took it. The next command sees a fresh status. + err = lora.standby(); + LOG_DEBUG("SX126x standby reported a stale command timeout, retry %s%d", radioLibErr, err); + } if (err != RADIOLIB_ERR_NONE) LOG_DEBUG_RADIO("SX126x standby %s%d", radioLibErr, err); @@ -504,20 +517,39 @@ template void SX126xInterface::startReceive() /** Is the channel currently active? */ template bool SX126xInterface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, + // check if we can detect a LoRa preamble on the current channel. + // NOTE: symNum is the *encoded* SET_CAD_PARAMS value, not a raw count - RADIOLIB_SX126X_CAD_ON_4_SYMB + // (== raw 2) runs the 4-symbol scan getCadSymbolCountSubGhz() reports; keep the two in step. + // Exit CAD straight into RX on detection (GOTO_RX) with the RX IRQs already mapped, so the chip's + // own CAD->RX transition delivers RX_DONE with no library call in between. irqFlags is + // the status-enable set (CAD verdict + full RX set incl. PREAMBLE/HEADER_VALID for isActivelyReceiving); + // irqMask is the DIO-trigger set - CAD verdict + terminal RX events only, NOT PREAMBLE/HEADER_VALID, + // which would fire the ISR mid-frame and run readData() on an incomplete packet. + const uint32_t cadIrqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS; + const uint32_t cadIrqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR); + // cadTimeout bounds the RX the chip enters on detection: one max-length airtime, in microseconds. A + // detection that delivers nothing then expires to standby and raises TIMEOUT, which re-arms us. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + ChannelScanConfig_t cfg = {.cad = {.symNum = RADIOLIB_SX126X_CAD_ON_4_SYMB, .detPeak = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, .detMin = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = RADIOLIB_SX126X_CAD_GOTO_RX, + .timeout = cadRxTimeoutUsec, + .irqFlags = cadIrqFlags, + .irqMask = cadIrqMask}}; setTransmitEnable(false); int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX (GOTO_RX). Drop the latched CAD verdict so the pin releases and the + // coming RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_SX126X_IRQ_CAD_DONE | RADIOLIB_SX126X_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } if (result != RADIOLIB_CHANNEL_FREE) LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); if (result != RADIOLIB_ERR_WRONG_MODEM) diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index cdfc986ff5..c5eab14d10 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -83,6 +83,9 @@ template class SX126xInterface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // Sub-GHz only. isChannelActive() passes CAD_ON_4_SYMB; keep the two in step. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + private: #ifdef LORA_DIO1_SOFTWARE_POLL bool irqPollingActive = false; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 246c09dc70..948e6f83c2 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -10,6 +10,10 @@ #include "PortduinoGlue.h" #endif +// Peak-to-noise decision threshold for the CAD, the only CAD sensitivity control on this part. +// Reset value 0x32, well above every threshold AN1200.77 recommends. +#define SX1280_REG_CAD_DET_PEAK 0x0942 + // Particular boards might define a different max power based on what their hardware can do #if ARCH_PORTDUINO #define SX128X_MAX_POWER portduino_config.sx128x_max_power @@ -66,7 +70,8 @@ template bool SX128xInterface::init() if (!reinitChip(/*fromInit=*/true)) return false; - startReceive(); // start receiving + applyCadDetPeak(); // init() does not route through reconfigure(), so set it here too + startReceive(); // start receiving return true; } @@ -205,6 +210,8 @@ template bool SX128xInterface::reconfigure() LOG_INFO("SX128x recovered after re-init"); } + applyCadDetPeak(); // depends on the sf/bw just set + startReceive(); // restart receiving return true; @@ -215,6 +222,34 @@ template void SX128xInterface::clearRadioIsr() lora.clearDio1Action(); } +/** Set the CAD peak-to-noise threshold for the current sf/bw. */ +template void SX128xInterface::applyCadDetPeak() +{ + // AN1200.77 Table 1 calibrated PNR thresholds, rows BW 200/400/800/1600 kHz, columns SF5..SF12. + // The note measures these for a 4-symbol window but states they apply to any window duration. + // A new bandwidth or SF outside these needs a row adding; unmatched leaves the chip's own default. + static constexpr uint8_t CAD_DET_PEAK[4][8] = { + {22, 22, 23, 25, 25, 28, 32, 36}, // 203.125 kHz + {20, 21, 22, 23, 25, 25, 29, 32}, // 406.25 kHz + {20, 21, 21, 23, 25, 26, 27, 29}, // 812.5 kHz + {19, 21, 21, 23, 24, 24, 27, 28}, // 1625 kHz + }; + static constexpr float TABLE_BW_KHZ[4] = {203.125f, 406.25f, 812.5f, 1625.0f}; + int bwIdx = -1; + for (int i = 0; i < 4; i++) { + if (bw > TABLE_BW_KHZ[i] - 1.0f && bw < TABLE_BW_KHZ[i] + 1.0f) + bwIdx = i; + } + // Written through our own Module rather than lora.writeRegister(), which RadioLib keeps protected + // unless RADIOLIB_LOW_LEVEL unlocks raw register access across every driver. Same SPI transaction: + // SX128x::writeRegister() is a straight SPIwriteRegisterBurst, and begin() has already pointed + // spiConfig at the 0x18 WriteRegister opcode by the time reconfigure() runs. + if (bwIdx >= 0 && sf >= 5 && sf <= 12) { + const uint8_t detPeak = CAD_DET_PEAK[bwIdx][sf - 5]; + module.SPIwriteRegisterBurst(SX1280_REG_CAD_DET_PEAK, &detPeak, 1); + } +} + template bool SX128xInterface::wideLora() { return true; @@ -347,8 +382,16 @@ template void SX128xInterface::startReceive() /** Is the channel currently active? */ template bool SX128xInterface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD_24GHZ, + // check if we can detect a LoRa preamble on the current channel. + // symNum is the encoded SET_CAD_PARAMS value (symbol count in bits 7:5), not a raw count - pass the + // CAD_ON_n_SYMB constant matching what getCadSymbolCount() reports, or the slot stops matching. + // The PNR threshold is written once per config by applyCadDetPeak(), not here: nothing between the + // scan and the transmit that follows it should cost an extra SPI round trip. + + // detPeak/detMin/exitMode below are ignored: SetCadParams (0x88) carries only cadSymbolNum, and + // RadioLib's setCad() writes just that one byte. CAD always exits to STDBY_RC, so a busy channel + // needs the caller's full re-arm; there is no in-chip CAD->RX handoff on this part. + ChannelScanConfig_t cfg = {.cad = {.symNum = RADIOLIB_SX128X_CAD_ON_8_SYMB, .detPeak = 0, .detMin = 0, .exitMode = 0, diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 967142c49a..13c222bcf1 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -75,6 +75,12 @@ template class SX128xInterface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // 2.4 GHz only. isChannelActive() passes CAD_ON_8_SYMB; keep the two in step. + uint8_t getCadSymbolCountWideLora() const override { return 8; } + + /** Set the CAD peak-to-noise threshold for the current sf/bw. Call after either changes. */ + void applyCadDetPeak(); + private: /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ int16_t programModemParams(); @@ -88,5 +94,11 @@ template class SX128xInterface : public RadioLibInterface int16_t trySetStandby(); /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ - bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } + bool recoverChipStateLoss() override + { + if (!reinitChip() || programModemParams() != RADIOLIB_ERR_NONE) + return false; + applyCadDetPeak(); // the reset took the CAD threshold with the rest of the chip state + return true; + } }; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 450951214a..7e10a3ed78 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1148,9 +1148,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif #if !MESHTASTIC_EXCLUDE_GPS - // Enable gps if it was previously disabled due to region not being set - if (!requiresReboot && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && - !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + // Enable gps if it was previously disabled due to region not being set. Only then: a probe that + // gave up also leaves it disabled, and re-enabling on every LoRa save re-runs the blocking probe. + if (!requiresReboot && oldLoraConfig.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && + config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && !gps->isEnabled() && + config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { gps->enable(); } #endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 713d539f7a..e2838c36fa 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -398,6 +398,56 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN } #ifdef ARCH_ESP32 +/** + * The GPIO an SX126x/SX128x/LR part raises its IRQ on, or -1 if there is no usable pin. + * + * Nearly every variant spells it LORA_DIO1, but an SX1280 board may define SX128X_DIO1 on a different + * pin (t-eth-elite, tlora_t3s3_v1) or as the only one of the two (tlora_v2_1_18, betafpv_2400_tx_micro), + * because that is the name RadioInterface hands to the driver. Resolve it per radio rather than per + * macro, so arming, polling and teardown below cannot pick different pins. RF95 keeps RF95_IRQ. + */ +static int loraWakeDio1Pin() +{ +#if defined(SX128X_DIO1) && (SX128X_DIO1 != RADIOLIB_NC) + if (radioType == SX1280_RADIO) + return (int)SX128X_DIO1; +#endif +#if defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) + return (int)LORA_DIO1; +#endif + return -1; +} + +/** + * Is the LoRa IRQ line already asserted right now? + * + * DIO1 (SX126x/SX128x) and RF95_IRQ are active-high, so a HIGH level means the radio has already + * latched an interrupt (typically an RX_DONE). If that edge lands in the small window between + * enableLoraInterrupt() and esp_light_sleep_start(), the DIO ISR fires and detachInterrupt() clears + * the per-pin GPIO wake enable as a side effect - the node would then sleep with the packet latched + * but the wake source disarmed, stranding it for the rest of the sleep chunk (up to 30s). Reading the + * raw line just before sleeping lets us skip that sleep and let the main loop service the packet. + * Boards where DIO1 is a virtual/expander pin never reach here - doLightSleep() returns early on + * LORA_DIO1_EXTENDED_IO before this is called. + */ +static bool loraWakeIrqAsserted() +{ +#if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) + if (radioType == RF95_RADIO) + return gpio_get_level((gpio_num_t)RF95_IRQ) == 1; +#endif + const int dio1 = loraWakeDio1Pin(); + if (dio1 >= 0 && radioType != RF95_RADIO) + return gpio_get_level((gpio_num_t)dio1) == 1; + return false; +} + +// Consecutive sleeps skipped because the LoRa IRQ was already asserted. A line stuck high would +// otherwise stop the node sleeping for good, so give up on skipping once it stops looking transient. +static uint8_t loraIrqSkippedSleeps = 0; +#define LORA_IRQ_SKIP_SLEEP_WARN 3 +#define LORA_IRQ_SKIP_SLEEP_MAX 5 + /** * enter light sleep (preserves ram but stops everything about CPU). * @@ -510,7 +560,21 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r assert(res == ESP_OK); console->flush(); - res = esp_light_sleep_start(); + const bool loraIrqPending = loraWakeIrqAsserted() && loraIrqSkippedSleeps < LORA_IRQ_SKIP_SLEEP_MAX; + if (loraIrqPending) { + // A LoRa IRQ is already pending (see loraWakeIrqAsserted). Don't sleep on top of it - return so + // the main loop services the packet now instead of stranding it until the next wake. + if (++loraIrqSkippedSleeps >= LORA_IRQ_SKIP_SLEEP_WARN) + LOG_WARN("LoRa IRQ still pending after %u skipped sleeps", loraIrqSkippedSleeps); + else + LOG_INFO("LoRa IRQ pending, skip light sleep to service packet"); + res = ESP_OK; + } else { + // Either nothing pending, or the line has stayed high past LORA_IRQ_SKIP_SLEEP_MAX and the main + // loop is clearly not going to clear it. Never sleeping costs more than one stranded packet. + loraIrqSkippedSleeps = 0; + res = esp_light_sleep_start(); + } if (res != ESP_OK) { LOG_ERROR("esp_light_sleep_start result %d", res); } @@ -541,9 +605,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // Unconditional: the config can have changed while we were asleep. gpio_wakeup_disable((gpio_num_t)MOTION_WAKE_INT_PIN); #endif -#if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) - if (radioType != RF95_RADIO) { - gpio_wakeup_disable((gpio_num_t)LORA_DIO1); +#if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && (defined(LORA_DIO1) || defined(SX128X_DIO1)) + const int wokeDio1 = loraWakeDio1Pin(); // must match what enableLoraInterrupt() armed + if (wokeDio1 >= 0 && radioType != RF95_RADIO) { + gpio_wakeup_disable((gpio_num_t)wokeDio1); } #endif #if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) @@ -552,7 +617,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r } #endif - esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); + // When we skipped the sleep because a LoRa IRQ was already pending, esp_sleep_get_wakeup_cause() + // would report the previous real sleep's cause (e.g. TIMER); synthesize a GPIO cause so the + // handling below (and PowerFSM) takes the packet-service path instead of a stale timer path. + esp_sleep_wakeup_cause_t cause = loraIrqPending ? ESP_SLEEP_WAKEUP_GPIO : esp_sleep_get_wakeup_cause(); notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here if (cause == ESP_SLEEP_WAKEUP_GPIO) { @@ -612,11 +680,14 @@ void enableLoraInterrupt() { #if defined(LORA_DIO1_EXTENDED_IO) // DIO1 is a virtual pin on an I/O expander - it cannot be a GPIO wakeup source -#elif SOC_PM_SUPPORT_EXT_WAKEUP && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) +#elif SOC_PM_SUPPORT_EXT_WAKEUP && (defined(LORA_DIO1) || defined(SX128X_DIO1)) + const int loraDio1 = loraWakeDio1Pin(); esp_err_t res; - res = gpio_pulldown_en((gpio_num_t)LORA_DIO1); - if (res != ESP_OK) { - LOG_ERROR("gpio_pulldown_en(LORA_DIO1) result %d", res); + if (loraDio1 >= 0) { + res = gpio_pulldown_en((gpio_num_t)loraDio1); + if (res != ESP_OK) { + LOG_ERROR("gpio_pulldown_en(lora dio1) result %d", res); + } } #if defined(LORA_RESET) && (LORA_RESET != RADIOLIB_NC) res = gpio_pullup_en((gpio_num_t)LORA_RESET); @@ -632,13 +703,16 @@ void enableLoraInterrupt() loraFEMInterface.setRxModeEnableWhenMCUSleep(); #endif - LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); - gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); + if (loraDio1 >= 0) { + LOG_INFO("Wake on LoRa DIO1 (GPIO%02d) gpio interrupt", loraDio1); + gpio_wakeup_enable((gpio_num_t)loraDio1, GPIO_INTR_HIGH_LEVEL); + } -#elif defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) - if (radioType != RF95_RADIO) { - LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); - gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); // SX126x/SX128x interrupt, active high +#elif defined(LORA_DIO1) || defined(SX128X_DIO1) + const int loraDio1 = loraWakeDio1Pin(); + if (loraDio1 >= 0 && radioType != RF95_RADIO) { + LOG_INFO("Wake on LoRa DIO1 (GPIO%02d) gpio interrupt", loraDio1); + gpio_wakeup_enable((gpio_num_t)loraDio1, GPIO_INTR_HIGH_LEVEL); // SX126x/SX128x interrupt, active high } #endif #if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) 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()); } diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index 6ac17146a4..fd2f7a70f1 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -43,6 +43,20 @@ extra_scripts = variants/nrf52840/diy/nrf52_promicro_diy_tcxo/custom_build_tasks.py ; Add to PIO's Project Tasks pane: preset builds for common displays +; NRF52 ProMicro w/ 2.4 GHz SX1280 module (e.g. Ebyte E28) - TXEN wired to P1.06 +[env:nrf52_promicro_diy-sx1280] +board_level = extra +extends = nrf52840_base +board = promicro-nrf52840 +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo + -D NRF52_PROMICRO_DIY + -D PROMICRO_SX1280 ; 2.4 GHz module fitted; claims P1.06 as TXEN + -D EXCLUDE_EMOJI ; as the base env - one more radio driver family leaves even less room for emote bitmaps +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> +debug_tool = jlink + ; NRF52 ProMicro w/ EasyProMicro pinout [env:nrf52_promicro_diy-easypromicro] board_level = extra diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 5a6f0074e5..cb16a13500 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -36,7 +36,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT | P1.00 | BUTTON_PIN |   | P1.13   | CS           | CS   | | P0.11 | SCL         |   | P1.11   | SCK         | SCK | | P1.04 | SDA         |   | P0.10   | DIO1/IRQ     | DIO1 | -| P1.06 | Free pin   |   | P0.09   | RESET       | RST | +| P1.06 | TXEN (opt) | | P0.09 | RESET | RST | |       |             |   |         |             | | |       | Mid board   |   |         | Internal     | | | P1.01 | Free pin   |   | 0.15     | LED         | | @@ -181,15 +181,30 @@ https://github.com/brad112358/easy_E22 #define RF95_RXEN (0 + 17) // P0.17 #define RF95_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +// P1.06 is the only free pin left for a TXEN line, but it is also the e-ink panel's BUSY line. +// EasyProMicro, where it is SCL, takes the #else branch below and has its own TXEN on P1.00. +#if !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(USE_EINK) +#define PROMICRO_P106_FREE_FOR_TXEN +#endif + +#if defined(PROMICRO_SX126X_TXEN) && defined(PROMICRO_SX1280) +#error "PROMICRO_SX126X_TXEN and PROMICRO_SX1280 both claim P1.06 as TXEN - pick one" +#endif + // SX126X CONFIG -#define SX126X_CS (32 + 13) // P1.13 FIXME - we really should define LORA_CS instead +#define SX126X_CS LORA_CS #define SX126X_DIO1 (0 + 10) // P0.10 IRQ #define SX126X_DIO2_AS_RF_SWITCH // Note for E22 modules: DIO2 is not attached internally to TXEN for automatic TX/RX switching, // so it needs connecting externally if it is used in this way #define SX126X_BUSY (0 + 29) // P0.29 #define SX126X_RESET (0 + 9) // P0.09 #define SX126X_RXEN (0 + 17) // P0.17 -#define SX126X_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +// Opt in with -D PROMICRO_SX126X_TXEN when P1.06 is wired to an external PA's TXEN. +#if defined(PROMICRO_SX126X_TXEN) && defined(PROMICRO_P106_FREE_FOR_TXEN) +#define SX126X_TXEN (32 + 6) // P1.06 +#else +#define SX126X_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +#endif // LR1121 #ifdef USE_LR1121 @@ -215,6 +230,19 @@ https://github.com/brad112358/easy_E22 #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 #endif +// SX128X CONFIG - 2.4 GHz only, so off by default: it is a different module on the same footprint, +// and an E28 needs P1.06 for TXEN. Opt in with -D PROMICRO_SX1280 (env nrf52_promicro_diy-sx1280). +#if defined(PROMICRO_SX1280) && defined(PROMICRO_P106_FREE_FOR_TXEN) +#define USE_SX1280 +#define SX128X_CS LORA_CS // P1.13 +#define SX128X_DIO1 (0 + 10) // P0.10 IRQ +#define SX128X_BUSY (0 + 29) // P0.29 +#define SX128X_RESET (0 + 9) // P0.09 +#define SX128X_RXEN (0 + 17) // P0.17 +#define SX128X_TXEN (32 + 6) // P1.06 +// #define SX128X_MAX_POWER 3 // set this if the module has an external PA, e.g. E28-2G4M20S +#endif + #else // Easy E22 Promicro arrangement #define USE_SSD1306 // #define USE_SH1106