LoRa LBT: hold TX on a seen preamble instead of judging it by a deadline

receiveDetected() declared a bare PREAMBLE_DETECTED false once
2 * preambleTimeMsec passed without HEADER_VALID - about 8 ms at
SF7/BW500. Nothing reads the flags on that cadence: they are latched
status bits, never routed to DIO, and the only reader is the TX path,
once per CSMA backoff (0-56 ms at SF7/BW500). The verdict was usually
reached by a read older than its own deadline, and the queued TX went
out over the frame. Reported in #11933 by @jp-bennett, whose two-node
bench measured own TX a median 43 ms after the verdict, inside a ~102 ms
frame. This branch's longer CAD slot widens it to LONG_FAST: 0-308 ms of
backoff at CWmin against a 262 ms deadline.

A read that sees a bare preamble now clears PREAMBLE_DETECTED and holds
TX for one max-length airtime. The clear touches only the IRQ register
(ClearIrqStatus/ClearIrq on SX126x, SX1280, LR11x0, LR20x0), so it never
aborts a reception. Reads during the hold report busy, so the normal
random backoff still spreads retries, and any RX interrupt ends the hold
early. A preamble that fires again during the hold stays latched, and
the first read after it holds again: a source that keeps triggering keeps
deferring TX, one airtime at a time, while a single stale flag cannot
block it. HEADER_VALID is never cleared here - RadioLib's readData()
reads it at RX_DONE, and on LR2021 its absence reports the packet as
header-damaged - and keeps its one-airtime ageing.

The SX126x software IRQ poll routes its bare-preamble case through the
same hold instead of clearing on every tick while a TX is queued, which
would have released TX before the next read could see a refire.

That poll read the private txQueue directly, so meshnology_w10 - the
only env that defines LORA_DIO1_SOFTWARE_POLL - did not compile.
hasQueuedTx() exposes the one fact it needs.

#11937 addresses the same defect by deferring the verdict when the
previous read was stale; the two conflict in receiveDetected().
This commit is contained in:
nomdetom committed 2026-09-24 21:23:44 +01:00
1 parent 45262f8257
commit be3bf1f381
3 files changed
+54 -28

No files matched your search

+39 -19
View File
@@ -119,31 +119,50 @@ bool RadioLibInterface::canSendImmediately()
return true;
}
bool RadioLibInterface::preambleHoldActive()
{
// Whatever sent the cleared preamble is off the air one max packet later.
if (preambleHoldStart && !Throttle::isWithinTimespanMs(
preambleHoldStart, getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader))))
preambleHoldStart = 0;
return preambleHoldStart != 0;
}
void RadioLibInterface::holdOnPreamble()
{
// During a hold a refire stays latched, so the first look after it sees an external source and holds again.
if (preambleHoldActive())
return;
iface->clearIrq(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED);
preambleHoldStart = Time::skipZero(Time::getMillis());
LOG_TRACE("Preamble seen, cleared, holding TX");
}
bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag)
{
bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag));
// Handle false detections
if (detected) {
if (preambleHoldActive())
return true;
if (irq & syncWordHeaderValidFlag) {
if (!activeReceiveStart) {
activeReceiveStart = Time::skipZero(Time::getMillis());
} else if (!Throttle::isWithinTimespanMs(activeReceiveStart, 2 * preambleTimeMsec)) {
if (!(irq & syncWordHeaderValidFlag)) {
// The HEADER_VALID flag should be set by now if it was really a packet, so ignore PREAMBLE_DETECTED flag
activeReceiveStart = 0;
LOG_TRACE("Ignore false preamble detection");
return false;
} else {
uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader));
if (!Throttle::isWithinTimespanMs(activeReceiveStart, maxPacketTimeMsec)) {
// We should have gotten an RX_DONE IRQ by now if it was really a packet, so ignore HEADER_VALID flag
activeReceiveStart = 0;
LOG_TRACE("Ignore false header detection");
return false;
}
}
} else if (!Throttle::isWithinTimespanMs(activeReceiveStart,
getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)))) {
// We should have gotten an RX_DONE IRQ by now if it was really a packet, so ignore HEADER_VALID flag
activeReceiveStart = 0;
LOG_TRACE("Ignore false header detection");
return false;
}
return true;
}
return detected;
if (irq & preambleDetectedFlag) {
// Looks come once per CSMA backoff, too rarely to judge a preamble by symbol-time deadline (#11933).
// Clear it so the next look sees only a fresh one, and hold TX meanwhile; a clear never aborts RX.
holdOnPreamble();
return true;
}
return false;
}
/// Send a packet (possibly by enquing in a private fifo). This routine will
@@ -616,6 +635,7 @@ void RadioLibInterface::handleReceiveInterrupt()
// Condition?
const bool wasCadHandoff = cadHandoffRxStart != 0;
cadHandoffRxStart = 0; // this RX ends the wait either way; the outcome is logged below
preambleHoldStart = 0; // likewise the reception a held preamble announced
if (!isReceiving) {
LOG_ERROR("handleReceiveInterrupt called while not in rx mode");
+11
View File
@@ -352,6 +352,17 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
protected:
uint32_t activeReceiveStart = 0;
// Time::getMillis() when a look cleared PREAMBLE_DETECTED and began holding TX, or 0 if no hold.
uint32_t preambleHoldStart = 0;
/** True while a cleared preamble still holds TX; ends the hold once one max packet has passed. */
bool preambleHoldActive();
/** Clear a bare PREAMBLE_DETECTED and hold TX one max packet, unless a hold is already running. */
void holdOnPreamble();
/** 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);
+4 -9
View File
@@ -372,16 +372,11 @@ template <typename T> void SX126xInterface<T>::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;
// A bare PREAMBLE (no terminal RX event yet) means the chip is mid-reception. Do NOT treat it as a
// full RX event - that would repeatedly trigger readData(). The escape hatch (a noise preamble that
// never completes must not permanently block our own TX) runs only while a TX is queued, and clears
// ONLY the preamble. HEADER_VALID is evidence of a real inbound frame, so keep it latched: clearing
// it would make isActivelyReceiving() read idle mid-payload and let the queued TX stomp the frame.
// A stuck header can't block TX forever - receiveDetected() ages it out after maxPacketTimeMsec,
// and readData() clears everything once a real RX_DONE arrives.
// A bare PREAMBLE is mid-reception, not an RX event: readData() here would run on nothing. With a TX
// queued it goes through the same hold as the TX-path look; HEADER_VALID stays latched for readData().
const bool preambleOnly = (irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED) && !(irq & RADIOLIB_SX126X_IRQ_HEADER_VALID);
if (!pollTxMode && !txQueue.empty() && preambleOnly && ((irq & ~noisyRxMask) == 0U)) {
lora.clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED);
if (!pollTxMode && hasQueuedTx() && preambleOnly && ((irq & ~noisyRxMask) == 0U)) {
holdOnPreamble();
scheduleIrqPollTick();
return;
}