diff --git a/.gitignore b/.gitignore index aca3ac7..49fb807 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,5 @@ CLAUDE.md zephcore/apc_checklist.md zephcore/apc.md RADIO_AUDIT_INDEX.md +RX_BUSY_LATCH_PLAN.md +zephcore/SX126X_LBT_RX_DEBUG_HANDOFF.md diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index c24eb50..21c842e 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -216,9 +216,13 @@ loop(): - Flood packets: compute RX delay based on score → defer or process immediately - Direct packets: process immediately 4. checkSend(): Check outbound queue - - CAD: if channel busy, retry every 100-200ms (jittered) up to 4s total + - CAD: if channel busy (`isReceiving()` returns true or radio not ready), + retry every 100-200ms (jittered) up to 4s total. On 4s timeout, + call `_radio->recoverRxState()` (cancel + restart, clears IRQ + + latch + grace timestamp) and re-wake the loop instead of falling + through to TX. - Duty cycle: if exceeded, defer 5 seconds (admin packets exempt) - - Final LBT check right before TX (closes timing gap) + - Final `isReceiving()` check right before TX (closes timing gap) - Serialize and transmit ``` @@ -298,17 +302,50 @@ Compile-time selection via `CONFIG_ZEPHCORE_RADIO_LR1110` in `RadioIncludes.h`. ### 5.2 LoRaRadioBase State Machine -**TX Flow**: -1. `startSendRaw()` → cancel RX → configure TX → copy to buffer → async send → wake TX wait thread -2. TX wait thread blocks on semaphore, polls completion signal (5s timeout) -3. On DIO1 TX_DONE interrupt → signal raised → restart RX → update stats +**TX Flow** (LBT — current default; `cad.mode == LORA_CAD_MODE_LBT` is set unconditionally in `buildModemConfig`): +1. `startSendRaw()` → `isReceiving()` final gate → `_tx_active = 1` → **skip** `hwCancelReceive()` and leave `_in_recv_mode = 1` so the driver sees state == RX → `configureTx()` → async send. +2. SX126x `send_async` entry CAS accepts both `REST_STATE → TX` and `RX → TX`, recording `was_rx`. LBT branch issues `set_standby(RC)` then SetCAD. On CAD-busy: in-driver `sx126x_restart_rx` puts the chip back in RX before `-EBUSY` returns. C++ failure path calls `startReceive()`, which the driver's `lora_recv_async` short-circuits when state is already RX. +3. On TX success: `_in_recv_mode = 0`, TX wait thread blocks on semaphore (5 s timeout). +4. On DIO1 `TX_DONE` interrupt → signal raised → restart RX → update stats. **RX Flow**: -1. `lora_recv_async()` with callback -2. ISR writes to 8-slot SPSC ring buffer (drops NEW packet on overflow) -3. Main thread drains via `recvRaw()` +1. `lora_recv_async()` with callback. SX126x `recv_async` clears `IRQ_ALL` and resets the RX-busy signals on every fresh entry. +2. ISR writes to 8-slot SPSC ring buffer (drops NEW packet on overflow). +3. Main thread drains via `recvRaw()`. -**Config Caching**: Avoids redundant `lora_config()` calls. Fast-path for TX↔RX transitions when only direction differs. +**Config Caching**: Avoids redundant `lora_config()` calls. Fast-path for TX↔RX transitions when only direction differs. `recoverRxState()` clears the cache (`_config_cached = false`) so post-recovery RX goes through the full path. + +### 5.2.1 RX-Busy Gate (TX-during-RX prevention) + +`LoRaRadioBase::isReceiving()` is the single software source of truth for "currently receiving" and is consulted at three sites: dispatcher initial gate, dispatcher final gate, and `startSendRaw`'s last-moment gate. Logic: + +``` +isReceiving() + ├─ false if !_in_recv_mode || _tx_active + ├─ true if hwIsReceiving() ← per-adapter; never clears IRQ + └─ isChannelActive() RSSI fallback ← sub-preamble-threshold energy +``` + +For SX126x, `hwIsReceiving()` → `sx126x_is_receiving()` reads in this order: +1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. +2. **Mutex-busy conservative** — if the SPI mutex is contended and `state == RX`, return true (the work handler is likely mid-`RxDone`). +3. **`HEADER_VALID` raw bit** — covers the microseconds between DIO1 firing and the work handler running. +4. **`PREAMBLE_DETECTED` raw bit with SF-aware grace** — `PREAMBLE_DETECTED` is masked off DIO1 (fires on noise), but visible in the IRQ register. On first observation, `is_receiving` records `data->preamble_seen_at_ms`; subsequent calls return true until either `HEADER_VALID` promotes the latch (timestamp reset) or `(preamble_len + 8) × 2^SF / BW` ms elapses — at which point the bit is explicitly cleared and TX is allowed. Grace scales with SF: ~82 ms at SF8, ~786 ms at SF12. + +The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, and the grace-expiry one-bit clear for foreign preambles. + +### 5.2.2 CAD-Timeout Recovery + +`Dispatcher::checkSend()` tracks `cad_busy_start` while `isReceiving()` keeps the TX gate closed. If 4 s elapse (`getCADFailMaxDuration()`), the dispatcher calls `_radio->recoverRxState()` and returns. `LoRaRadioBase::recoverRxState()` does: + +```cpp +hwCancelReceive(); // RX → IDLE → STANDBY → SLEEP (REST_STATE) +atomic_set(&_in_recv_mode, 0); // resync C++ side +_config_cached = false; // force full lora_config on the way back +startReceive(); // CAS(REST → RX) clears latch + IRQ +``` + +This walks the chip through REST so the driver's `lora_recv_async` entry CAS (`REST_STATE → RX`) actually succeeds — a bare `startReceive()` from `state == RX` would fail with `-EBUSY` and set `_in_recv_mode = 0` while the driver still thinks it's in RX. After recovery, the dispatcher fires `_tx_queued_cb(1, ...)` to re-wake the loop promptly. ### 5.3 Noise Floor EMA diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index c8f4c7c..0709851 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -59,8 +59,12 @@ int16_t LR1110Radio::hwGetCurrentRSSI() return lr11xx_get_rssi_inst(_dev); } -bool LR1110Radio::hwIsPreambleDetected() +bool LR1110Radio::hwIsReceiving() { + /* MUST be non-destructive: never clear IRQ bits from this path. + * Foreign-preamble release is hardware-driven (chip-internal release + * on HEADER_ERROR / sync timeout). The driver's lr11xx_is_receiving() + * reads IRQ status without clearing. */ return lr11xx_is_receiving(_dev); } diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 6b60f8c..dba2558 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -25,7 +25,7 @@ protected: int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) override; int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; + bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwResetAGC() override; }; diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index 88f9ae7..a2dceeb 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -59,8 +59,12 @@ int16_t LR2021Radio::hwGetCurrentRSSI() return lr20xx_get_rssi_inst(_dev); } -bool LR2021Radio::hwIsPreambleDetected() +bool LR2021Radio::hwIsReceiving() { + /* MUST be non-destructive: never clear IRQ bits from this path. + * Foreign-preamble release is hardware-driven (chip-internal release + * on HEADER_ERROR / sync timeout). The driver's lr20xx_is_receiving() + * reads via get_status() without clearing. */ return lr20xx_is_receiving(_dev); } diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index 4edd3fb..5303a98 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -25,7 +25,7 @@ protected: int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) override; int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; + bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwResetAGC() override; }; diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 53f1c80..c2cb363 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -511,19 +511,35 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) return false; } - /* Last-moment hardware check before killing active RX. - * Closes the race between the Dispatcher's isReceiving() guard - * and hwCancelReceive() — if a preamble arrived in that gap, - * abort TX and let the Dispatcher re-queue. */ - if (hwIsPreambleDetected()) { + /* Last-moment software check before killing active RX. Uses the full + * isReceiving() (latch + non-destructive raw bits) so the final gate + * honors the same source of truth as the dispatcher's earlier gates. + * Closes the serialisation/logging gap between the dispatcher's check + * and the TX-state transition below. */ + if (isReceiving()) { return false; } _board->onBeforeTransmit(); atomic_set(&_tx_active, 1); - atomic_set(&_in_recv_mode, 0); - hwCancelReceive(); + /* Phase 2: when LBT is enabled, skip the pre-emptive hwCancelReceive() + * and keep _in_recv_mode = 1 so the driver's send_async sees state == RX + * (the Phase-2 entry CAS path). On CAD-busy the driver restores RX + * internally; on success the chip transitions cleanly into TX without + * the redundant ~1–3 ms C++ cancel-then-restart round-trip. + * isReceiving() returns false during the CAD window because _tx_active + * is set above — no extra gating needed. + * + * cad.mode = LBT is set unconditionally in buildModemConfig() today; + * the `lbt` flag is a placeholder for any future Kconfig that toggles + * the behaviour. */ + const bool lbt = true; + + if (!lbt) { + atomic_set(&_in_recv_mode, 0); + hwCancelReceive(); + } configureTx(); memcpy(_tx_buf, bytes, len); @@ -534,10 +550,20 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) LOG_ERR("hwSendAsync failed: %d", ret); _board->onAfterTransmit(); atomic_set(&_tx_active, 0); + /* startReceive() is safe to call here regardless of failure + * cause: on SX126x, recv_async early-returns if the driver + * already restored RX on CAD-busy (Phase 2 idempotent fast + * path); on LR11xx/LR20xx, the LBT branch restores RX before + * returning -EBUSY (Phase 2 mirror), so start_rx is also a + * no-op there. On other failure modes the chip is in REST, + * recv_async transitions normally. */ startReceive(); return false; } + /* TX has actually started — now we're no longer in RX. */ + atomic_set(&_in_recv_mode, 0); + LOG_DBG("TX started async, len=%d", len); k_sem_give(&_tx_start_sem); return true; @@ -748,12 +774,35 @@ bool LoRaRadioBase::isReceiving() if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { return false; } - if (hwIsPreambleDetected()) { + /* Driver-side latch + non-destructive IRQ read covers the full + * payload phase. hwIsReceiving() never clears IRQ bits; foreign + * preambles release via hardware (SymbNumTimeout on SX126x non-DC + * or chip-internal sync timer on DC / LR11xx / LR20xx). */ + if (hwIsReceiving()) { return true; } return isChannelActive(); } +void LoRaRadioBase::recoverRxState() +{ + /* Called by the Dispatcher on CAD timeout when isReceiving() has been + * pinned true past the recovery threshold (4 s). We must escape a + * stuck driver state == RX — a bare startReceive() can't do this + * because the driver's lora_recv_async entry CAS is REST_STATE → RX, + * which fails when state is already RX and would set _in_recv_mode = 0 + * on the -EBUSY return. Walk the chip back through REST first. + * + * The RX-restart sites in the driver (recv_async, recv_duty_cycle, + * restart_rx) all bulk-clear IRQ status and reset the rx_packet_active + * latch as part of their entry, so this sequence cleanly flushes a + * stuck PREAMBLE_DETECTED bit or a stale latch. */ + hwCancelReceive(); + atomic_set(&_in_recv_mode, 0); + _config_cached = false; + startReceive(); +} + bool LoRaRadioBase::isChannelActive(int threshold) { if (threshold == 0) { diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 4cf3afd..be7bbc0 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -70,6 +70,7 @@ public: void triggerNoiseFloorCalibrate(int threshold) override; void resetAGC() override; bool isReceiving() override; + void recoverRxState() override; /* Extended API */ bool isChannelActive(int threshold = 0); @@ -102,7 +103,9 @@ protected: virtual int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) = 0; virtual int16_t hwGetCurrentRSSI() = 0; - virtual bool hwIsPreambleDetected() = 0; + /* Non-destructive read of the radio's "currently receiving" signal — + * latch + raw IRQ bits, never clears. Backs LoRaRadioBase::isReceiving(). */ + virtual bool hwIsReceiving() = 0; virtual void hwSetRxBoost(bool enable) = 0; virtual void hwResetAGC() = 0; diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 1612d84..1f29aa1 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -66,8 +66,12 @@ int16_t SX126xRadio::hwGetCurrentRSSI() return sx126x_get_rssi_inst(_dev); } -bool SX126xRadio::hwIsPreambleDetected() +bool SX126xRadio::hwIsReceiving() { + /* MUST be non-destructive: never clear IRQ bits from this path. + * Foreign-preamble release is hardware-driven (SymbNumTimeout in + * non-DC, chip-internal in DC). The driver's sx126x_is_receiving() + * reads rx_packet_active latch + raw IRQ bits; never clears. */ return sx126x_is_receiving(_dev); } diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 745dad0..005c6a4 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -27,7 +27,7 @@ protected: int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) override; int16_t hwGetCurrentRSSI() override; - bool hwIsPreambleDetected() override; + bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwResetAGC() override; bool hwIsChipBusy() override; diff --git a/zephcore/adapters/radio/SX127xRadio.cpp b/zephcore/adapters/radio/SX127xRadio.cpp index bc46034..0d3bc4d 100644 --- a/zephcore/adapters/radio/SX127xRadio.cpp +++ b/zephcore/adapters/radio/SX127xRadio.cpp @@ -7,7 +7,7 @@ * provides via sx126x_ext.h are not available here: * * hwGetCurrentRSSI() — returns -80 dBm sentinel (no hardware path) - * hwIsPreambleDetected()— always false (no preamble-detect IRQ exposed) + * hwIsReceiving() — always false (no preamble/header IRQ exposed) * hwSetRxBoost() — no-op (SX127x has no RX boost register) * hwResetAGC() — no-op (loramac-node manages AGC internally) * hwIsChipBusy() — inherited false (no BUSY pin on SX127x) @@ -77,11 +77,14 @@ int16_t SX127xRadio::hwGetCurrentRSSI() return -80; } -bool SX127xRadio::hwIsPreambleDetected() +bool SX127xRadio::hwIsReceiving() { - /* No preamble-detect IRQ accessible through the standard Zephyr LoRa - * API for the loramac-node driver. Returning false means TX will - * not abort for an in-progress preamble — acceptable on SX127x. */ + /* MUST be non-destructive (trivially: stub returns false). + * No preamble/header IRQ accessible through the standard Zephyr LoRa + * API for the loramac-node driver. Returning false means the + * isReceiving() fallback uses isChannelActive() (RSSI-based) on + * SX127x — acceptable as a degraded path; SX127x is not the focus + * of this fix. */ return false; } diff --git a/zephcore/adapters/radio/SX127xRadio.h b/zephcore/adapters/radio/SX127xRadio.h index 2f9c7d1..443e0a1 100644 --- a/zephcore/adapters/radio/SX127xRadio.h +++ b/zephcore/adapters/radio/SX127xRadio.h @@ -33,9 +33,11 @@ protected: * will converge on this value rather than the real noise floor. */ int16_t hwGetCurrentRSSI() override; - /* SX127x has no preamble-detected IRQ accessible via standard API. - * Always returns false — TX will not abort for a detected preamble. */ - bool hwIsPreambleDetected() override; + /* SX127x has no preamble/header IRQ accessible via standard API. + * Always returns false — TX will not gate on the chip-level RX-busy + * signal. isReceiving() falls back to the RSSI-based isChannelActive() + * for SX127x users. */ + bool hwIsReceiving() override; /* SX127x has no RX boost / LNA gain switch via standard API. No-op. */ void hwSetRxBoost(bool enable) override; diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index d5ad465..33dc218 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -134,8 +134,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { initNodePrefs(&prefs); strcpy(prefs.node_name, "Repeater"); prefs.advert_loc_policy = ADVERT_LOC_PREFS; - prefs.flood_advert_interval = 25; - prefs.loop_detect = LOOP_DETECT_MINIMAL; + prefs.loop_detect = LOOP_DETECT_MODERATE; prefs.path_hash_mode = 1; /* Persist defaults so flash always has a prefs file from boot 1. * Lets later code (e.g. tempradio revert) trust that flash is @@ -233,7 +232,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (ret >= 0 && entry.size < 294) { prefs.rx_boost = 1; prefs.path_hash_mode = 1; - prefs.loop_detect = LOOP_DETECT_MINIMAL; + prefs.loop_detect = LOOP_DETECT_MODERATE; savePrefs(prefs); LOG_INF("loadPrefs: upgraded prefs format (%d -> 294 bytes)", (int)entry.size); } diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index d53cdc6..0aeb177 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -873,8 +873,7 @@ RepeaterMesh::RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Mil initNodePrefs(&_prefs); strcpy(_prefs.node_name, "Repeater"); _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // Repeaters always advertise prefs coordinates - _prefs.flood_advert_interval = 25; // hours - _prefs.loop_detect = LOOP_DETECT_MINIMAL; + _prefs.loop_detect = LOOP_DETECT_MODERATE; _prefs.path_hash_mode = 1; #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) memset(&_uplink_creds, 0, sizeof(_uplink_creds)); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index c788ff4..07727f9 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -105,8 +105,8 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->tx_power_dbm = 22; // LoRaConfig::TX_POWER_DBM #endif prefs->disable_fwd = 0; - prefs->advert_interval = 60; // 2 minutes (value / 2) - prefs->flood_advert_interval = 12; // 12 hours + prefs->advert_interval = 0; // 0 = periodic local advert off; else minutes = value * 2 + prefs->flood_advert_interval = 25; // hours prefs->rx_delay_base = 0.0f; prefs->tx_delay_factor = 0.5f; prefs->direct_tx_delay_factor = 0.3f; diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index 1be338c..9ec6a24 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -27,6 +27,13 @@ public: virtual bool isInRecvMode() const = 0; virtual bool isReceiving() { return false; } virtual bool isRadioReady() { return true; } + + /* Reset the radio back into a known good RX state. Called by the + * Dispatcher on CAD timeout (when isReceiving() pinned true past the + * recovery threshold). Default no-op — radios that can stall should + * override to walk the chip through cancel → REST → fresh RX, which + * also bulk-clears IRQ status and any internal busy latches. */ + virtual void recoverRxState() {} virtual float getLastRSSI() const { return 0; } virtual float getLastSNR() const { return 0; } diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c index c6666cf..ce8a6a5 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -639,10 +639,21 @@ static int lr11xx_lora_send_async(const struct device *dev, if (data->tx_active) return -EBUSY; if (data_len > 255 || data_len == 0) return -EINVAL; - /* LBT: perform blocking CAD before transmitting */ + /* LBT: perform blocking CAD before transmitting. On CAD-busy, restore + * RX in-driver before returning -EBUSY so the C++ layer doesn't have + * to do a full cancel-then-restart round-trip. lr11xx_lora_cad + * transitions the chip to STANDBY and clears data->in_rx_mode as + * part of running CAD; capture the pre-CAD state to know whether + * to re-arm. */ if (data->modem_cfg.cad.mode == LORA_CAD_MODE_LBT) { + bool was_in_rx = data->in_rx_mode; int cad_ret = lr11xx_lora_cad(dev, K_MSEC(200)); if (cad_ret > 0) { + if (was_in_rx && data->async_rx_cb != NULL) { + k_mutex_lock(&data->spi_mutex, K_FOREVER); + lr11xx_start_rx(data, cfg); + k_mutex_unlock(&data->spi_mutex); + } return -EBUSY; } if (cad_ret < 0 && cad_ret != -ENOSYS) { diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c index a99dddc..861c1de 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -853,11 +853,22 @@ static int lr20xx_lora_send_async(const struct device *dev, if (data->tx_active) return -EBUSY; if (data_len > 255 || data_len == 0) return -EINVAL; - /* LBT: perform blocking CAD before transmitting */ + /* LBT: perform blocking CAD before transmitting. On CAD-busy, restore + * RX in-driver before returning -EBUSY so the C++ layer doesn't have + * to do a full cancel-then-restart round-trip. lr20xx_lora_cad + * transitions the chip to STANDBY and clears data->in_rx_mode as + * part of running CAD; capture the pre-CAD state to know whether + * to re-arm. */ if (data->modem_cfg.cad.mode == LORA_CAD_MODE_LBT) { + bool was_in_rx = data->in_rx_mode; int cad_ret = lr20xx_lora_cad(dev, K_MSEC(200)); if (cad_ret > 0) { LOG_DBG("LBT: channel busy"); + if (was_in_rx && data->async_rx_cb != NULL) { + k_mutex_lock(&data->spi_mutex, K_FOREVER); + lr20xx_start_rx(data, cfg); + k_mutex_unlock(&data->spi_mutex); + } return -EBUSY; } if (cad_ret < 0 && cad_ret != -ENOSYS) { diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 619f667..dd59c8c 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -68,10 +68,15 @@ index 17689720dd2..09982dc2e70 100644 return -EINVAL; } diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c -index 30243ba5dc7..86c10f39fc3 100644 +index 30243ba5dc7..0cec01d84a9 100644 --- a/drivers/lora/native/sx126x/sx126x.c +++ b/drivers/lora/native/sx126x/sx126x.c -@@ -10,10 +10,16 @@ +@@ -6,14 +6,21 @@ + #include + #include + #include ++#include + #include #include #include "sx126x.h" @@ -80,7 +85,7 @@ index 30243ba5dc7..86c10f39fc3 100644 #include LOG_MODULE_REGISTER(sx126x, CONFIG_LORA_LOG_LEVEL); -+/* Dedicated DIO1 work queue — keeps LoRa interrupt processing off the ++/* Dedicated DIO1 work queue -- keeps LoRa interrupt processing off the + * system work queue so USB/BLE/timer work items cannot delay packet RX. */ +#define SX126X_DIO1_WQ_STACK_SIZE 2560 +K_THREAD_STACK_DEFINE(sx126x_dio1_wq_stack, SX126X_DIO1_WQ_STACK_SIZE); @@ -88,7 +93,46 @@ index 30243ba5dc7..86c10f39fc3 100644 #define SX126X_REST_STATE \ (IS_ENABLED(CONFIG_LORA_SX126X_NATIVE_SLEEP) \ ? SX126X_STATE_SLEEP : SX126X_STATE_IDLE) -@@ -222,6 +228,7 @@ static int sx126x_set_packet_params(const struct device *dev, +@@ -74,6 +81,38 @@ static uint32_t bandwidth_to_hz(enum lora_signal_bandwidth bw) + } + } + ++/* Reset all software state that indicates "we are currently receiving": ++ * the rx_packet_active latch and the preamble-grace timestamp. Paired ++ * write so the two fields never drift out of sync. Called from every ++ * RX (re)start site, every terminal-event handler (RX_DONE / CRC_ERR / ++ * RX_TX_TIMEOUT), and on TX-state entry. */ ++static inline void sx126x_reset_rx_busy_signals(struct sx126x_data *data) ++{ ++ data->rx_packet_active = false; ++ atomic_set(&data->preamble_seen_at_ms, 0); ++} ++ ++/* Grace period for the PREAMBLE_DETECTED -> HEADER_VALID gap, SF/BW-aware. ++ * (preamble_len + 8) symbols covers worst-case (early-detect at ~4 syms) ++ * preamble remainder + sync word (4.25 sym) + header decode (~5 sym) plus ++ * a safety margin. Used by sx126x_is_receiving() as the timeout after ++ * which a sticky PREAMBLE_DETECTED bit is assumed to be a foreign sync ++ * word and explicitly cleared. */ ++static uint32_t sx126x_preamble_grace_ms(struct sx126x_data *data) ++{ ++ uint8_t sf = (uint8_t)data->config.datarate; ++ uint32_t bw_hz = bandwidth_to_hz(data->config.bandwidth); ++ uint16_t preamble = data->config.preamble_len; ++ ++ if (bw_hz == 0 || sf < 5 || sf > 12) { ++ return 1000; /* safe default ~1 s if config is uninitialised */ ++ } ++ /* (preamble + 8) syms * 2^sf * 1000000 / bw_hz -> us; +999/1000 -> ms */ ++ uint64_t us = ((uint64_t)(preamble + 8U) << sf) * 1000000ULL / bw_hz; ++ ++ return (uint32_t)((us + 999U) / 1000U); ++} ++ + static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth bw, + const struct sx126x_hal_config *config) + { +@@ -222,6 +261,7 @@ static int sx126x_set_packet_params(const struct device *dev, uint8_t invert_iq) { uint8_t buf[6]; @@ -96,7 +140,7 @@ index 30243ba5dc7..86c10f39fc3 100644 sys_put_be16(preamble_len, &buf[0]); buf[2] = header_type; -@@ -229,7 +236,31 @@ static int sx126x_set_packet_params(const struct device *dev, +@@ -229,7 +269,31 @@ static int sx126x_set_packet_params(const struct device *dev, buf[4] = crc_mode; buf[5] = invert_iq; @@ -106,13 +150,13 @@ index 30243ba5dc7..86c10f39fc3 100644 + return ret; + } + -+ /* §15.4 IQ Polarity workaround (datasheet errata). ++ /* Sec 15.4 IQ Polarity workaround (datasheet errata). + * After SetPacketParams, register 0x0736 bit 2 must be: + * SET for standard IQ (non-inverted) + * CLEAR for inverted IQ + * Without this fix, inverted-IQ packets are not received. + * -+ * Must read-modify-write — SetPacketParams actively writes to ++ * Must read-modify-write -- SetPacketParams actively writes to + * this register and other bits may vary. Cannot cache. */ + uint8_t iq_val; + @@ -129,7 +173,7 @@ index 30243ba5dc7..86c10f39fc3 100644 } static int sx126x_set_sync_word(const struct device *dev, bool public_network) -@@ -247,6 +278,13 @@ static int sx126x_set_rx_gain(const struct device *dev, bool boosted) +@@ -247,6 +311,13 @@ static int sx126x_set_rx_gain(const struct device *dev, bool boosted) { uint8_t val = boosted ? SX126X_RX_GAIN_BOOSTED : SX126X_RX_GAIN_POWER_SAVING; @@ -143,7 +187,7 @@ index 30243ba5dc7..86c10f39fc3 100644 return sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &val, 1); } -@@ -296,7 +334,7 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -296,7 +367,7 @@ static int sx126x_get_packet_status(const struct device *dev, uint8_t buf[3]; int ret; @@ -152,13 +196,13 @@ index 30243ba5dc7..86c10f39fc3 100644 if (ret == 0) { /* RSSI is -value/2 dBm */ *rssi = -((int16_t)buf[0] >> 1); -@@ -367,6 +405,26 @@ static int sx126x_chip_init(const struct device *dev) +@@ -367,6 +438,26 @@ static int sx126x_chip_init(const struct device *dev) return ret; } + /* After TX/RX, fall back to FS mode instead of the default STDBY_RC. + * FS keeps both XOSC and PLL active, so the next SetRx skips both the -+ * ~500 us XOSC startup AND the ~50-100 us PLL settle — minimising the ++ * ~500 us XOSC startup AND the ~50-100 us PLL settle -- minimising the + * deaf window between packets. Costs ~5-10 mA while in this state, but + * we are typically only here for the brief ISR-to-handler window before + * sleep or the next operation. Critical for duty-cycle restart where @@ -179,27 +223,30 @@ index 30243ba5dc7..86c10f39fc3 100644 /* Set packet type to LoRa */ ret = sx126x_set_packet_type(dev, SX126X_PACKET_TYPE_LORA); if (ret < 0) { -@@ -374,10 +432,16 @@ static int sx126x_chip_init(const struct device *dev) +@@ -374,10 +465,19 @@ static int sx126x_chip_init(const struct device *dev) return ret; } - /* Configure IRQs on DIO1: TX done, RX done, timeout */ -+ /* Global mask includes preamble/header so the status register bits are -+ * set (enabling sx126x_is_receiving() polling); DIO1 excludes them to -+ * avoid spurious work-queue wakeups on every detected preamble. */ ++ /* Global mask includes preamble + header so both are visible in the IRQ ++ * status register for sx126x_is_receiving() to poll non-destructively. ++ * DIO1 excludes PREAMBLE_DETECTED only -- it fires on noise/foreign ++ * traffic and would cause spurious work-queue wakeups. HEADER_VALID ++ * is routed to DIO1 because it fires at most once per real packet ++ * (after sync-word match + header CRC) and is the trigger that ++ * promotes the rx_packet_active latch in the work handler. */ uint16_t irq_mask = SX126X_IRQ_TX_DONE | SX126X_IRQ_RX_DONE | - SX126X_IRQ_RX_TX_TIMEOUT | SX126X_IRQ_CRC_ERR; - ret = sx126x_set_dio_irq_params(dev, irq_mask, irq_mask, 0, 0); + SX126X_IRQ_RX_TX_TIMEOUT | SX126X_IRQ_CRC_ERR | + SX126X_IRQ_CAD_DONE | SX126X_IRQ_CAD_ACTIVITY_DETECTED | + SX126X_IRQ_PREAMBLE_DETECTED | SX126X_IRQ_HEADER_VALID; -+ uint16_t dio1_mask = irq_mask & -+ ~(SX126X_IRQ_PREAMBLE_DETECTED | SX126X_IRQ_HEADER_VALID); ++ uint16_t dio1_mask = irq_mask & ~SX126X_IRQ_PREAMBLE_DETECTED; + ret = sx126x_set_dio_irq_params(dev, irq_mask, dio1_mask, 0, 0); if (ret < 0) { LOG_ERR("Set IRQ params failed: %d", ret); return ret; -@@ -398,7 +462,7 @@ static void sx126x_dio1_callback(const struct device *dev) +@@ -398,7 +498,7 @@ static void sx126x_dio1_callback(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -208,13 +255,13 @@ index 30243ba5dc7..86c10f39fc3 100644 } static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx) -@@ -406,7 +470,20 @@ static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx) +@@ -406,7 +506,20 @@ static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx) const struct sx126x_hal_config *config = dev->config; sx126x_hal_set_antenna_enable(dev, enable); - if (!config->dio2_tx_enable) { + if (config->dio2_tx_enable) { -+ /* DIO2 handles TX enable in hardware — but rx-enable-gpios ++ /* DIO2 handles TX enable in hardware -- but rx-enable-gpios + * (e.g. E22-900M30S RXEN) and some board-specific tx-enable + * lines still need explicit GPIO control. + * @@ -230,29 +277,36 @@ index 30243ba5dc7..86c10f39fc3 100644 sx126x_hal_set_rf_switch(dev, enable, tx); } } -@@ -455,6 +532,80 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) +@@ -455,6 +568,98 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) } #endif /* CONFIG_PM_DEVICE */ -+/* ── Lightweight RX restart ─────────────────────────────────────────── */ ++/* -- Lightweight RX restart -- */ + -+/* Restart RX as fast as possible — used for RX→RX transitions in the ++/* Restart RX as fast as possible -- used for RX->RX transitions in the + * IRQ handler. Issues SetRx(continuous) directly without going through + * the standby/sleep round-trip that lora_recv_async would do. */ +static int sx126x_restart_rx(const struct device *dev, struct sx126x_data *data) +{ + int ret; + ++ /* Clear any stale IRQ bits before we re-enter RX, so a leftover ++ * PREAMBLE_DETECTED from a partial reception cannot pin ++ * sx126x_is_receiving() true on the next cycle. Also resets the ++ * software latch + preamble-grace timestamp -- old packet is done. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_ALL); ++ sx126x_reset_rx_busy_signals(data); ++ + if (data->rx_duty_cycle_enabled) { + const struct sx126x_hal_config *config = dev->config; + + /* AGC reset: after RxDone in duty cycle mode the chip is in -+ * STDBY_RC — the analog frontend (incl. AGC) is already ++ * STDBY_RC -- the analog frontend (incl. AGC) is already + * powered down. Calibrate(ALL) re-initialises ADC/PLL/RC + * so the receiver starts clean. Without this, a strong + * signal can desensitise the receiver until reboot (the + * "near-far" gain-lock problem). -+ * Cost: ~5 ms per received packet — well within the 32.8 ms ++ * Cost: ~5 ms per received packet -- well within the 32.8 ms + * preamble budget (16-sym preamble, need 8 to lock). */ + sx126x_calibrate(dev, SX126X_CALIBRATE_ALL); + k_busy_wait(5000); @@ -269,13 +323,13 @@ index 30243ba5dc7..86c10f39fc3 100644 + sx126x_calibrate_image(dev, data->config.frequency); + } + -+ /* Re-apply DIO2 as RF switch — Calibrate resets it */ ++ /* Re-apply DIO2 as RF switch -- Calibrate resets it */ + if (config->dio2_tx_enable) { + sx126x_set_dio2_as_rf_switch(dev, true); + } + + /* StopTimerOnPreamble: without it, the duty-cycle RX timer -+ * keeps running even after a preamble is detected — a ++ * keeps running even after a preamble is detected -- a + * preamble arriving at the tail of the RX window can get + * cut off when the sleep phase begins. Must be re-issued + * every time because Calibrate(ALL) above resets it. */ @@ -290,6 +344,17 @@ index 30243ba5dc7..86c10f39fc3 100644 + sx126x_hal_write_cmd(dev, SX126X_CMD_SET_RX_DUTY_CYCLE, + dc_buf, 6); + } else { ++ /* Non-DC continuous RX: plain SetRx(continuous), no symbol ++ * timer. An earlier draft armed SetLoRaSymbNumTimeout here for ++ * "foreign-preamble auto-release", but the chip's symbol timer ++ * counts down from SetRx regardless of preamble detection (with ++ * STOP_TIMER_ON_PREAMBLE = 0). In a quiet bench it therefore ++ * fired IRQ_RX_TX_TIMEOUT every ~N symbols, causing a ++ * (timeout → restart_rx) busy-poll cycle that raced with TX ++ * attempts. Foreign-preamble defense is instead handled by ++ * is_receiving() gating on HEADER_VALID only (raw bit) — the ++ * level-latched PREAMBLE_DETECTED bit is ignored there, so a ++ * foreign preamble does not pin the TX gate. */ + uint8_t buf[3]; + + sys_put_be24(SX126X_RX_TIMEOUT_CONTINUOUS, buf); @@ -300,7 +365,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + * sx126x_hal_write_cmd() above waits for BUSY before issuing the command; + * sx126x_hal_write_regs() below also waits for BUSY (the ~3.4 ms RC startup + * after SetRx) before writing. The gain is therefore applied after the radio -+ * has entered RX but before any preamble can arrive — giving a clean AGC ++ * has entered RX but before any preamble can arrive -- giving a clean AGC + * state for each new packet regardless of the previous packet's signal level. */ + uint8_t gain = data->rx_boost_enabled ? SX126X_RX_GAIN_BOOSTED + : SX126X_RX_GAIN_POWER_SAVING; @@ -311,7 +376,7 @@ index 30243ba5dc7..86c10f39fc3 100644 static int sx126x_set_sleep(const struct device *dev) { struct sx126x_data *data = dev->data; -@@ -533,6 +684,9 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -533,8 +738,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta struct sx126x_data *data = dev->data; struct sx126x_rx_result result = { 0 }; uint8_t payload_len = 0, offset = 0; @@ -320,8 +385,15 @@ index 30243ba5dc7..86c10f39fc3 100644 + atomic_val_t rx_cb_gen = 0; int ret; ++ /* Terminal event for the in-flight packet -- drop the RX-busy latch ++ * and preamble-grace timestamp. Covers both success and CRC_ERR ++ * (they co-fire here). */ ++ sx126x_reset_rx_busy_signals(data); ++ /* Get received packet info */ -@@ -564,20 +718,34 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta + ret = sx126x_get_rx_buffer_status(dev, &payload_len, &offset); + if (ret < 0) { +@@ -564,20 +777,34 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta } } @@ -342,7 +414,7 @@ index 30243ba5dc7..86c10f39fc3 100644 - result.rssi, result.snr, - data->rx_cb_user_data); + if (rx_cb != NULL) { -+ /* Restart RX FIRST — minimise the deaf window. ++ /* Restart RX FIRST -- minimise the deaf window. + * The callback (mesh processing) can take 100s of us; + * doing it before restart would lose back-to-back packets. + * Safe: we're on a cooperative work queue so the next @@ -368,7 +440,7 @@ index 30243ba5dc7..86c10f39fc3 100644 } } else { /* Sync mode */ -@@ -589,8 +757,46 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -589,8 +816,69 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta static void sx126x_handle_irq_timeout(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -376,12 +448,21 @@ index 30243ba5dc7..86c10f39fc3 100644 LOG_DBG("Timeout"); + ++ /* Terminal event -- clear the RX-busy latch and preamble-grace ++ * timestamp. Fires for DC preamble false-positive re-arm and any ++ * future non-DC timeout path. */ ++ sx126x_reset_rx_busy_signals(data); ++ ++ if (data->cad_active) { ++ return; ++ } ++ + k_mutex_lock(&data->lock, K_FOREVER); + rx_cb = data->rx_cb; + k_mutex_unlock(&data->lock); + + /* Duty-cycle RX: a preamble false-positive (noise, partial preamble -+ * from another modulation) can fire RxTimeout — but the chip has left ++ * from another modulation) can fire RxTimeout -- but the chip has left + * duty cycle and is parked in STDBY_RC. Without this re-arm the + * driver silently falls out of duty cycle after the first noisy + * window and goes to sleep. Re-entering via sx126x_restart_rx() @@ -411,12 +492,26 @@ index 30243ba5dc7..86c10f39fc3 100644 + } + return; + } ++ ++ /* Non-DC continuous RX safety net: SetRx(0xFFFFFF) means no symbol ++ * timer, so IRQ_RX_TX_TIMEOUT should not normally fire here. But if ++ * any future setup (or a chip glitch) does fire it while async RX is ++ * active, re-arm rather than silently falling back to sleep. */ ++ if (!data->rx_duty_cycle_enabled && rx_cb != NULL) { ++ int ret = sx126x_restart_rx(dev, data); ++ ++ if (ret < 0) { ++ LOG_WRN("Non-DC timeout re-arm failed (%d)", ret); ++ (void)sx126x_set_sleep(dev); ++ } ++ return; ++ } + sx126x_set_sleep(dev); if (data->tx_async_signal != NULL) { -@@ -637,6 +843,25 @@ static void sx126x_irq_work_handler(struct k_work *work) - sx126x_handle_irq_timeout(dev); +@@ -633,10 +921,46 @@ static void sx126x_irq_work_handler(struct k_work *work) + sx126x_handle_irq_rx_done(dev, irq_status); } + if (irq_status & SX126X_IRQ_CAD_DONE) { @@ -437,11 +532,32 @@ index 30243ba5dc7..86c10f39fc3 100644 + k_sem_give(&data->cad_sem); + } + } ++ + if (irq_status & SX126X_IRQ_RX_TX_TIMEOUT) { + sx126x_handle_irq_timeout(dev); + } + ++ /* HEADER_VALID: promote rx_packet_active latch so sx126x_is_receiving() ++ * keeps reporting busy across the payload phase, not just during the ++ * narrow window before the work handler runs. Gated on no terminal ++ * bit also being set in this same handler pass -- terminal events ++ * (RX_DONE / CRC_ERR / RX_TX_TIMEOUT) own the false-write and must win ++ * on co-fire. CAD_DONE is handled before RX_TX_TIMEOUT so restart_rx ++ * cannot abort an in-flight CAD. */ ++ if ((irq_status & SX126X_IRQ_HEADER_VALID) && ++ !(irq_status & (SX126X_IRQ_RX_DONE | SX126X_IRQ_RX_TX_TIMEOUT | ++ SX126X_IRQ_CRC_ERR))) { ++ data->rx_packet_active = true; ++ /* Latch is the truth source now; the preamble-grace timestamp ++ * is stale and we don't want a future is_receiving() to treat ++ * it as live when the latch eventually clears. */ ++ atomic_set(&data->preamble_seen_at_ms, 0); ++ } + /* Re-enable the DIO1 interrupt for the next event (unless sleeping) */ if (atomic_get(&data->state) != SX126X_REST_STATE) { sx126x_hal_dio1_irq_enable(dev); -@@ -682,7 +907,7 @@ static int sx126x_lora_config(const struct device *dev, +@@ -682,7 +1006,7 @@ static int sx126x_lora_config(const struct device *dev, /* Configure PA and TX power based on chip variant and frequency */ ret = sx126x_hal_configure_tx_params(dev, config->tx_power, config->frequency, @@ -450,11 +566,11 @@ index 30243ba5dc7..86c10f39fc3 100644 if (ret < 0) { goto out; } -@@ -698,6 +923,29 @@ static int sx126x_lora_config(const struct device *dev, +@@ -698,6 +1022,29 @@ static int sx126x_lora_config(const struct device *dev, goto out; } -+ /* §15.1 TX Modulation workaround (datasheet errata). ++ /* Sec 15.1 TX Modulation workaround (datasheet errata). + * For 500 kHz BW, clear bit 2 of register 0x0889. + * For all other bandwidths, set bit 2 (restore default). */ + { @@ -480,7 +596,7 @@ index 30243ba5dc7..86c10f39fc3 100644 /* Set sync word */ ret = sx126x_set_sync_word(dev, config->public_network); if (ret < 0) { -@@ -721,6 +969,8 @@ out: +@@ -721,6 +1068,8 @@ out: return ret; } @@ -489,22 +605,48 @@ index 30243ba5dc7..86c10f39fc3 100644 static int sx126x_lora_send_async(const struct device *dev, uint8_t *data_buf, uint32_t data_len, struct k_poll_signal *async) -@@ -752,6 +1002,65 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -738,11 +1087,27 @@ static int sx126x_lora_send_async(const struct device *dev, + return -EINVAL; + } + ++ /* Entry: accept transition from either REST_STATE or RX to TX. ++ * RX -> TX is the Phase 2 fast path that lets us track was_rx and have ++ * the LBT branch restore RX in-driver on CAD-busy, saving the ~1-3 ms ++ * C++ round-trip. Either CAS succeeds -> we own the state; both fail -> ++ * a concurrent TX is already in progress. */ ++ bool was_rx = false; ++ + if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_TX)) { +- LOG_ERR("Busy"); +- return -EBUSY; ++ if (!atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_TX)) { ++ LOG_ERR("Busy"); ++ return -EBUSY; ++ } ++ was_rx = true; + } + ++ /* TX is starting (or about to, after LBT): the RX-busy latch and ++ * preamble-grace timestamp are now stale. If LBT-busy restores RX ++ * below, restart_rx will reset them again as part of the fresh RX ++ * cycle. */ ++ sx126x_reset_rx_busy_signals(data); ++ + k_mutex_lock(&data->lock, K_FOREVER); + + ret = sx126x_ensure_ready(dev); +@@ -752,6 +1117,58 @@ static int sx126x_lora_send_async(const struct device *dev, return ret; } + /* LBT: perform blocking CAD before transmitting */ + if (data->config.cad.mode == LORA_CAD_MODE_LBT) { -+ bool was_rx = (atomic_get(&data->state) == SX126X_STATE_RX); -+ bool was_rx_dc = was_rx && data->rx_duty_cycle_enabled; -+ + k_mutex_unlock(&data->lock); + atomic_set(&data->state, SX126X_REST_STATE); + int cad_ret = sx126x_lora_cad(dev, K_MSEC(200)); + if (cad_ret > 0) { + LOG_DBG("LBT: channel busy"); + k_mutex_lock(&data->lock, K_FOREVER); -+ data->rx_duty_cycle_enabled = was_rx_dc; + if (was_rx && data->rx_cb != NULL && + atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { + sx126x_restart_rx(dev, data); @@ -515,7 +657,6 @@ index 30243ba5dc7..86c10f39fc3 100644 + if (cad_ret < 0 && cad_ret != -ENOSYS) { + LOG_WRN("LBT: CAD failed (%d), restoring RX", cad_ret); + k_mutex_lock(&data->lock, K_FOREVER); -+ data->rx_duty_cycle_enabled = was_rx_dc; + if (was_rx && data->rx_cb != NULL && + atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { + sx126x_restart_rx(dev, data); @@ -528,7 +669,6 @@ index 30243ba5dc7..86c10f39fc3 100644 + SX126X_STATE_TX)) { + LOG_ERR("Busy after CAD"); + k_mutex_lock(&data->lock, K_FOREVER); -+ data->rx_duty_cycle_enabled = was_rx_dc; + if (was_rx && data->rx_cb != NULL && + atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { + sx126x_restart_rx(dev, data); @@ -542,7 +682,6 @@ index 30243ba5dc7..86c10f39fc3 100644 + k_mutex_unlock(&data->lock); + atomic_set(&data->state, SX126X_REST_STATE); + k_mutex_lock(&data->lock, K_FOREVER); -+ data->rx_duty_cycle_enabled = was_rx_dc; + if (was_rx && data->rx_cb != NULL && + atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { + sx126x_restart_rx(dev, data); @@ -555,11 +694,11 @@ index 30243ba5dc7..86c10f39fc3 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,6 +1086,29 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,6 +1194,29 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); -+ /* §15.2 TX Clamp workaround (datasheet errata) — SX1262 only. ++ /* Sec 15.2 TX Clamp workaround (datasheet errata) -- SX1262 only. + * Set bits [4:1] of register 0x08D8 before SetTx to prevent + * PA overshoot that can damage the device. */ + { @@ -585,7 +724,7 @@ index 30243ba5dc7..86c10f39fc3 100644 /* Start transmission with 10 second timeout */ ret = sx126x_set_tx(dev, 10000); if (ret < 0) { -@@ -847,6 +1179,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, +@@ -847,6 +1287,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, } data->rx_cb = NULL; @@ -594,16 +733,47 @@ index 30243ba5dc7..86c10f39fc3 100644 k_msgq_purge(&data->rx_msgq); /* Set packet parameters for variable length reception */ -@@ -918,6 +1252,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -918,6 +1360,8 @@ static int sx126x_lora_recv_async(const struct device *dev, /* Stop async reception */ data->rx_cb = NULL; data->rx_cb_user_data = NULL; + atomic_inc(&data->rx_cb_gen); ++ sx126x_reset_rx_busy_signals(data); if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { sx126x_set_standby(dev, SX126X_STANDBY_RC); sx126x_set_sleep(dev); -@@ -947,6 +1282,8 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -932,6 +1376,19 @@ static int sx126x_lora_recv_async(const struct device *dev, + return -EINVAL; + } ++ /* Phase 2 idempotent fast-path: if the driver is already in RX (e.g., ++ * because the LBT path in send_async restored RX on CAD-busy), just ++ * refresh the callback and return. The chip is in valid RX, the ++ * IRQ register has been cleared by the prior restart_rx, and the ++ * software latch has been reset. No re-init needed. */ ++ if (atomic_get(&data->state) == SX126X_STATE_RX) { ++ data->rx_cb = cb; ++ data->rx_cb_user_data = user_data; ++ atomic_inc(&data->rx_cb_gen); ++ k_mutex_unlock(&data->lock); ++ return 0; ++ } ++ + if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { + LOG_ERR("Busy"); + k_mutex_unlock(&data->lock); +@@ -945,8 +1402,18 @@ static int sx126x_lora_recv_async(const struct device *dev, + return ret; + } + ++ /* Fresh RX start: bulk-clear any stale IRQ bits (e.g., a stuck ++ * PREAMBLE_DETECTED that survived an error path) and reset the latch ++ * + preamble-grace timestamp. Without this, recoverRxState() from ++ * the dispatcher cannot actually recover -- the chip would come back ++ * into RX with the same bits set. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_ALL); ++ sx126x_reset_rx_busy_signals(data); ++ data->rx_cb = cb; data->rx_cb_user_data = user_data; + atomic_inc(&data->rx_cb_gen); @@ -611,7 +781,7 @@ index 30243ba5dc7..86c10f39fc3 100644 /* Set packet parameters */ ret = sx126x_set_packet_params(dev, -@@ -959,6 +1296,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -959,6 +1426,7 @@ static int sx126x_lora_recv_async(const struct device *dev, SX126X_LORA_IQ_INVERTED : SX126X_LORA_IQ_STANDARD); if (ret < 0) { data->rx_cb = NULL; @@ -619,7 +789,7 @@ index 30243ba5dc7..86c10f39fc3 100644 sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); return ret; -@@ -971,11 +1309,21 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -971,11 +1439,21 @@ static int sx126x_lora_recv_async(const struct device *dev, ret = sx126x_set_rx(dev, 0); if (ret < 0) { data->rx_cb = NULL; @@ -630,7 +800,7 @@ index 30243ba5dc7..86c10f39fc3 100644 return ret; } -+ /* Re-apply RX gain after SetRx — register 0x08AC is reset on every ++ /* Re-apply RX gain after SetRx -- register 0x08AC is reset on every + * SetRx without retention, and lora_config sets gain before going to + * sleep, so without this the first packet would be received in + * power-saving gain regardless of DTS rx-boosted. */ @@ -641,7 +811,7 @@ index 30243ba5dc7..86c10f39fc3 100644 k_mutex_unlock(&data->lock); return 0; } -@@ -1051,7 +1399,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1051,7 +1529,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, /* Set PA config and TX power */ ret = sx126x_hal_configure_tx_params(dev, tx_power, frequency, @@ -650,11 +820,11 @@ index 30243ba5dc7..86c10f39fc3 100644 if (ret < 0) { sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); -@@ -1083,14 +1431,513 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1083,14 +1561,602 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, return 0; } -+/* ── Extension API (sx126x_ext.h) ──────────────────────────────────── */ ++/* -- Extension API (sx126x_ext.h) -- */ + +bool sx126x_is_chip_busy(const struct device *dev) +{ @@ -667,7 +837,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + uint8_t buf[1]; + int ret; + -+ /* GPIO read — bail before stalling wait_busy on the duty-cycle ++ /* GPIO read -- bail before stalling wait_busy on the duty-cycle + * sleep phase. wait_busy's recovery would force-wake the chip and + * break duty cycle. */ + if (sx126x_hal_is_busy(dev)) { @@ -697,20 +867,75 @@ index 30243ba5dc7..86c10f39fc3 100644 + struct sx126x_data *data = dev->data; + uint16_t irq_status = 0; + ++ /* Primary source of truth: software latch set by the work handler on ++ * HEADER_VALID, cleared by terminal events (RX_DONE / CRC_ERR / ++ * RX_TX_TIMEOUT) and RX (re)start sites. Covers the entire payload ++ * phase without an SPI access. */ ++ if (data->rx_packet_active) { ++ return true; ++ } ++ + if (k_mutex_lock(&data->lock, K_NO_WAIT) != 0) { -+ return false; ++ /* Mutex contended. Conservative: if the chip should be in RX, ++ * assume we're receiving (the work handler is likely mid-RxDone ++ * holding the mutex). Avoids racing TX into a packet that's ++ * being decoded right now. */ ++ return atomic_get(&data->state) == SX126X_STATE_RX; + } + + sx126x_get_irq_status(dev, &irq_status); -+ /* Preamble/header IRQ bits are level-latched until cleared. -+ * Read-and-clear here so transient detect events don't pin -+ * is_receiving() true forever and deadlock TX gating. */ -+ sx126x_clear_irq_status(dev, SX126X_IRQ_PREAMBLE_DETECTED | -+ SX126X_IRQ_HEADER_VALID); -+ k_mutex_unlock(&data->lock); + -+ return (irq_status & (SX126X_IRQ_PREAMBLE_DETECTED | -+ SX126X_IRQ_HEADER_VALID)) != 0; ++ /* HEADER_VALID raw bit: narrow window between DIO1 firing and the work ++ * handler running. Latch will take over within microseconds. */ ++ if (irq_status & SX126X_IRQ_HEADER_VALID) { ++ k_mutex_unlock(&data->lock); ++ return true; ++ } ++ ++ /* PREAMBLE_DETECTED with SF-aware grace. PREAMBLE_DETECTED is masked ++ * off DIO1 (chip_init) so the work handler never auto-clears it for ++ * us, and the chip never auto-clears it on foreign-sync-word ++ * mismatch either. Without a software bound, treating it as ++ * "receiving" pins TX forever on a foreign preamble; treating it as ++ * "not receiving" lets TX abort a real packet in the ++ * PREAMBLE_DETECTED -> HEADER_VALID gap (up to ~530 ms at SF12). ++ * ++ * Compromise: track the first observation timestamp. Within ++ * preamble + sync + header airtime ("grace"), keep returning true so ++ * a real packet has time to land its HEADER_VALID and promote the ++ * latch. After grace expires, assume foreign sync word and clear ++ * the bit so TX is allowed. Grace scales with SF: ~82 ms at SF8, ++ * ~786 ms at SF12. */ ++ if (irq_status & SX126X_IRQ_PREAMBLE_DETECTED) { ++ uint32_t now = k_uptime_get_32(); ++ uint32_t seen = (uint32_t)atomic_get(&data->preamble_seen_at_ms); ++ ++ if (seen == 0) { ++ /* First observation in this RX cycle. Use 1 as the ++ * "I am set" sentinel if k_uptime returns 0 at boot. */ ++ atomic_set(&data->preamble_seen_at_ms, ++ (atomic_val_t)(now == 0 ? 1U : now)); ++ k_mutex_unlock(&data->lock); ++ return true; ++ } ++ if ((now - seen) < sx126x_preamble_grace_ms(data)) { ++ k_mutex_unlock(&data->lock); ++ return true; ++ } ++ /* Grace expired with no HEADER_VALID: assume foreign sync ++ * word, release the bit so TX can proceed. Hardware CAD ++ * inside send_async still gates over actually-busy channel. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_PREAMBLE_DETECTED); ++ atomic_set(&data->preamble_seen_at_ms, 0); ++ k_mutex_unlock(&data->lock); ++ return false; ++ } ++ ++ /* No preamble bit, no header bit: nothing in flight. Defensive ++ * reset in case a stale timestamp survived a mode change. */ ++ atomic_set(&data->preamble_seen_at_ms, 0); ++ k_mutex_unlock(&data->lock); ++ return false; +} + + @@ -741,14 +966,14 @@ index 30243ba5dc7..86c10f39fc3 100644 + return; + } + -+ /* Warm sleep — powers down the analog frontend (resets AGC state) ++ /* Warm sleep -- powers down the analog frontend (resets AGC state) + * but preserves register configuration. */ + uint8_t sleep_cfg = SX126X_SLEEP_WARM_START; + + sx126x_hal_write_cmd(dev, SX126X_CMD_SET_SLEEP, &sleep_cfg, 1); + k_busy_wait(500); + -+ /* Wake to STANDBY_RC — required before Calibrate */ ++ /* Wake to STANDBY_RC -- required before Calibrate */ + sx126x_set_standby(dev, SX126X_STANDBY_RC); + + /* Full recalibration: ADC, PLL, image, RC oscillators */ @@ -773,7 +998,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + } + + /* Chip is now in STANDBY. Update state so lora_recv_async() can -+ * transition back to RX — without this, the CAS(REST→RX) fails ++ * transition back to RX -- without this, the CAS(REST->RX) fails + * because state was still SX126X_STATE_RX from before the reset. */ + atomic_set(&data->state, SX126X_STATE_IDLE); + @@ -800,7 +1025,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + uint8_t val = 0; + + /* Set LSB of undocumented register 0x8B5 to improve RX performance. -+ * Described by Heltec engineer @Quency-D in MeshCore PR#1398 — ++ * Described by Heltec engineer @Quency-D in MeshCore PR#1398 -- + * consistently improves reception on boards with GC1109/KCT8103L PA + * (Heltec V4/V4.3). Must be called after lora_config(). */ + k_mutex_lock(&data->lock, K_FOREVER); @@ -810,7 +1035,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + k_mutex_unlock(&data->lock); +} + -+/* ── Driver API: CAD ────────────────────────────────────────────────── */ ++/* -- Driver API: CAD -- */ + +/* Recommended cad_detect_peak values per SF for 2-symbol CAD. + * From Semtech SX1261/62/68 datasheet AN1200.48. */ @@ -834,6 +1059,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + uint8_t sf = (uint8_t)mc->datarate; + uint8_t symb_nb = 2; + uint8_t detect_peak = sx126x_cad_detect_peak(sf); ++ int ret; + + if (mc->cad.symbol_num != 0) { + symb_nb = (uint8_t)mc->cad.symbol_num; @@ -842,6 +1068,15 @@ index 30243ba5dc7..86c10f39fc3 100644 + detect_peak = mc->cad.detection_peak; + } + ++ /* SET_CAD must run from STANDBY_RC (DS). LBT is entered from continuous RX ++ * (Phase-2 send_async CAS); sx126x_ensure_ready() is a no-op when native ++ * sleep is disabled, so the modem can still be in RX here -- CAD then never ++ * completes and k_sem_take times out. */ ++ ret = sx126x_set_standby(dev, SX126X_STANDBY_RC); ++ if (ret < 0) { ++ return ret; ++ } ++ + /* SetCadParams: symb_nb, detect_peak, detect_min, exit_mode, + * timeout (3 bytes, unused for STANDBYRC exit) */ + uint8_t buf[7]; @@ -854,7 +1089,10 @@ index 30243ba5dc7..86c10f39fc3 100644 + buf[5] = 0; /* timeout[15:8] */ + buf[6] = 0; /* timeout[7:0] */ + -+ sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD_PARAMS, buf, 7); ++ ret = sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD_PARAMS, buf, 7); ++ if (ret < 0) { ++ return ret; ++ } + + sx126x_clear_irq_status(dev, SX126X_IRQ_CAD_DONE | + SX126X_IRQ_CAD_ACTIVITY_DETECTED); @@ -862,7 +1100,11 @@ index 30243ba5dc7..86c10f39fc3 100644 + + /* Enable antenna in RX mode for CAD */ + sx126x_set_rf_path(dev, true, false); -+ sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD, NULL, 0); ++ ret = sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD, NULL, 0); ++ if (ret < 0) { ++ data->cad_active = false; ++ return ret; ++ } + + return 0; +} @@ -876,7 +1118,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + return -EINVAL; + } + -+ /* Transition from REST → IDLE for the CAD operation */ ++ /* Transition from REST -> IDLE for the CAD operation */ + if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_IDLE)) { + /* If we're in RX, force to IDLE */ + if (!atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { @@ -906,10 +1148,19 @@ index 30243ba5dc7..86c10f39fc3 100644 + } + + ret = k_sem_take(&data->cad_sem, timeout); -+ if (ret == -EAGAIN) { ++ if (ret != 0) { ++ /* Zephyr may return -EAGAIN or -ETIMEDOUT on wait timeout. */ ++ k_mutex_lock(&data->lock, K_FOREVER); + data->cad_active = false; ++ (void)sx126x_set_standby(dev, SX126X_STANDBY_RC); ++ sx126x_clear_irq_status(dev, SX126X_IRQ_CAD_DONE | ++ SX126X_IRQ_CAD_ACTIVITY_DETECTED); ++ k_mutex_unlock(&data->lock); + atomic_set(&data->state, SX126X_REST_STATE); -+ return -ETIMEDOUT; ++ if (ret == -EAGAIN || ret == -ETIMEDOUT) { ++ return -ETIMEDOUT; ++ } ++ return ret; + } + + atomic_set(&data->state, SX126X_REST_STATE); @@ -961,7 +1212,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + return ret; +} + -+/* ── Driver API: recv_duty_cycle ────────────────────────────────────── */ ++/* -- Driver API: recv_duty_cycle -- */ + +static int sx126x_lora_recv_duty_cycle(const struct device *dev, + k_timeout_t rx_period, @@ -978,9 +1229,10 @@ index 30243ba5dc7..86c10f39fc3 100644 + data->rx_cb_user_data = NULL; + atomic_inc(&data->rx_cb_gen); + data->rx_duty_cycle_enabled = false; ++ sx126x_reset_rx_busy_signals(data); + if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { + /* If we're called during the duty-cycle sleep phase the -+ * chip is actually asleep and BUSY is asserted — issuing ++ * chip is actually asleep and BUSY is asserted -- issuing + * SetStandby straight away stalls the HAL in + * wait_busy(). Poke CS via sx126x_hal_wakeup() first so + * BUSY drops before we try to talk to the chip. */ @@ -1011,13 +1263,20 @@ index 30243ba5dc7..86c10f39fc3 100644 + return ret; + } + ++ /* Fresh DC RX start: clear any stale IRQ bits and reset the latch + ++ * preamble-grace timestamp. Same rationale as the non-DC recv_async ++ * path -- recoverRxState() and other paths can't trust the IRQ ++ * register otherwise. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_ALL); ++ sx126x_reset_rx_busy_signals(data); ++ + /* AGC reset on every entry: Calibrate(ALL) re-initialises the analog + * frontend (ADC/PLL/RC) so the receiver starts with a clean AGC state. + * Without this, an AGC lock-up from a strong adjacent-channel signal + * persists across the cycle (the hardware-driven duty cycle never + * fires an IRQ to trigger sx126x_restart_rx's calibration path), and + * post-TX entry inherits whatever state the chip was in before TX. -+ * Cost: ~5 ms — paid once per entry, not per internal duty-cycle wake. */ ++ * Cost: ~5 ms -- paid once per entry, not per internal duty-cycle wake. */ + { + const struct sx126x_hal_config *hal_cfg = dev->config; + @@ -1036,7 +1295,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + * EU868 / 433 / 779 MHz operation. */ + sx126x_calibrate_image(dev, data->config.frequency); + -+ /* Re-apply DIO2 as RF switch — Calibrate resets it. */ ++ /* Re-apply DIO2 as RF switch -- Calibrate resets it. */ + if (hal_cfg->dio2_tx_enable) { + sx126x_set_dio2_as_rf_switch(dev, true); + } @@ -1171,7 +1430,7 @@ index 30243ba5dc7..86c10f39fc3 100644 }; #ifdef CONFIG_PM_DEVICE -@@ -1112,6 +1959,7 @@ static int sx126x_pm_action(const struct device *dev, +@@ -1112,6 +2178,7 @@ static int sx126x_pm_action(const struct device *dev, static int sx126x_init(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1179,7 +1438,7 @@ index 30243ba5dc7..86c10f39fc3 100644 int ret; /* Initialize data structures */ -@@ -1121,9 +1969,25 @@ static int sx126x_init(const struct device *dev) +@@ -1121,9 +2188,26 @@ static int sx126x_init(const struct device *dev) k_msgq_init(&data->rx_msgq, (char *)&data->rx_result, sizeof(struct sx126x_rx_result), 1); k_work_init(&data->irq_work, sx126x_irq_work_handler); @@ -1191,6 +1450,7 @@ index 30243ba5dc7..86c10f39fc3 100644 + data->rx_cb_user_data = NULL; + atomic_set(&data->rx_cb_gen, 0); + data->rx_duty_cycle_enabled = false; ++ sx126x_reset_rx_busy_signals(data); + /* Mirror the DTS rx-boosted property so restart_rx and recv_duty_cycle + * pick up the boost setting even when the user never calls the + * sx126x_set_rx_boost() extension API. */ @@ -1206,10 +1466,10 @@ index 30243ba5dc7..86c10f39fc3 100644 /* Initialize HAL */ ret = sx126x_hal_init(dev); diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h -index 9dbf3f26586..1fc232b9f78 100644 +index 9dbf3f26586..880b3240d22 100644 --- a/drivers/lora/native/sx126x/sx126x.h +++ b/drivers/lora/native/sx126x/sx126x.h -@@ -56,13 +56,36 @@ struct sx126x_data { +@@ -56,13 +56,58 @@ struct sx126x_data { /* Async RX callback */ lora_recv_cb rx_cb; void *rx_cb_user_data; @@ -1226,12 +1486,34 @@ index 9dbf3f26586..1fc232b9f78 100644 + /* Extension features (duty cycle, boost) */ + bool rx_duty_cycle_enabled; + bool rx_boost_enabled; ++ ++ /* RX-busy latch: set by the work handler when HEADER_VALID fires for ++ * a real packet, cleared on RX_DONE / CRC_ERR / RX_TX_TIMEOUT / RX ++ * (re)start / TX-state entry. Read by sx126x_is_receiving() as the ++ * primary source of truth for "payload phase in progress" so the TX ++ * gate stays busy across the entire packet, not just during the ++ * one-shot preamble/header IRQ window. */ ++ bool rx_packet_active; ++ ++ /* Timestamp (k_uptime_get_32() units, ms) of the first observation of ++ * PREAMBLE_DETECTED by sx126x_is_receiving() in the current RX cycle. ++ * Zero means "no preamble being tracked". Used to bridge the gap ++ * between PREAMBLE_DETECTED (IRQ register only, not on DIO1) and ++ * HEADER_VALID (which sets rx_packet_active via the work handler). ++ * Real packets fire HEADER_VALID within an SF-aware grace period and ++ * the latch takes over; foreign-sync-word preambles never produce ++ * HEADER_VALID and are released by sx126x_is_receiving() when grace ++ * expires (it clears PREAMBLE_DETECTED and resets this field). ++ * Reset together with rx_packet_active at every RX (re)start, every ++ * terminal-event handler, and on TX-state entry. */ ++ atomic_t preamble_seen_at_ms; ++ + uint32_t dc_rx_time; /* stored duty cycle rx period (15.625us steps) */ + uint32_t dc_sleep_time; /* stored duty cycle sleep period (15.625us steps) */ + + /* Duty-cycle preamble false-positive counter: incremented whenever + * IRQ_RX_TX_TIMEOUT fires during duty-cycle RX (preamble detector -+ * tripped but no valid sync/header followed — re-arm triggered). ++ * tripped but no valid sync/header followed -- re-arm triggered). + * High values indicate a noisy RF environment or preamble-detect + * threshold too loose; each event extends the real RX time past + * the nominal rx_period, increasing current draw. */ @@ -1308,15 +1590,15 @@ index b2dfc0d75b5..8ab8a0f5738 100644 return 0; diff --git a/drivers/lora/native/sx126x/sx126x_regs.h b/drivers/lora/native/sx126x/sx126x_regs.h -index 7f55c9b96e2..76c5319a73c 100644 +index 7f55c9b96e2..a616f0e6cbc 100644 --- a/drivers/lora/native/sx126x/sx126x_regs.h +++ b/drivers/lora/native/sx126x/sx126x_regs.h @@ -180,6 +180,11 @@ #define SX126X_RX_GAIN_POWER_SAVING 0x94 #define SX126X_RX_GAIN_BOOSTED 0x96 -+/* RX Gain Retention (DS §9.6) — tells chip to preserve 0x08AC across -+ * mode transitions (sleep/standby/TX → RX). Without retention, the ++/* RX Gain Retention (DS Sec 9.6) -- tells chip to preserve 0x08AC across ++ * mode transitions (sleep/standby/TX -> RX). Without retention, the + * chip resets RX gain to power-saving on every SetRx command. */ +#define SX126X_REG_RX_GAIN_RETENTION_0 0x029F + diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index d8a38be..d9f2b1f 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -400,6 +400,18 @@ void Dispatcher::checkSend() _radio->getNoiseFloor(), (unsigned)_radio->getPacketsRecv(), (unsigned)_radio->getPacketsRecvErrors()); + /* With the non-destructive sx126x_is_receiving() we lost + * the accidental side-effect IRQ clear that used to break + * us out of stuck preamble bits. Walk the chip back + * through REST → fresh RX, which bulk-clears IRQ status + * and resets the rx_packet_active latch. Then re-wake the + * loop promptly so the next checkSend() retries TX. */ + _radio->recoverRxState(); + cad_busy_start = 0; + if (_tx_queued_cb) { + _tx_queued_cb(1, _tx_queued_user_data); + } + return; } else { uint32_t retry = getCADFailRetryDelay(); next_tx_time = futureMillis((int)retry);