From bf71542751f3c3b937685bed1c29c2f342ccb5d3 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:23:01 +0200 Subject: [PATCH] Make Rxdutycycling Great Again --- zephcore/ARCHITECTURE.md | 2 +- zephcore/Kconfig | 61 +++- zephcore/Repeater_CLI_commands.md | 4 +- .../adapters/datastore/ZephyrDataStore.cpp | 14 +- zephcore/adapters/radio/LR1110Radio.cpp | 5 + zephcore/adapters/radio/LR1110Radio.h | 1 + zephcore/adapters/radio/LoRaRadioBase.cpp | 111 +++++-- zephcore/adapters/radio/LoRaRadioBase.h | 13 + zephcore/adapters/radio/SX126xRadio.cpp | 5 + zephcore/adapters/radio/SX126xRadio.h | 1 + zephcore/app/RepeaterDataStore.cpp | 6 - zephcore/boards/example_board/README.md | 3 +- .../drivers/lora/lr11xx/lr11xx_lora.c | 134 +++++++- .../drivers/lora/lr11xx/lr11xx_lora.h | 13 + .../drivers/lora/lr20xx/lr20xx_lora.c | 85 ++--- .../drivers/lora/native/sx126x/sx126x_ext.h | 14 + .../zephyr/0003-lora-sx126x-native.patch | 299 +++++++++++++----- 17 files changed, 564 insertions(+), 207 deletions(-) diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 4313a9a..1b98875 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -364,7 +364,7 @@ The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: - **RX buffer drift**: Buffer base shifts 4 bytes per packet → `clear_rxbuffer()` after every RX - **Header error**: Can shift buffer pointer → standby before RX restart - **DIO1 stuck HIGH**: 5-cycle detection → full hardware reset + recovery -- **RX duty cycle broken**: 23-40% packet loss → disabled, always continuous RX +- **RX duty cycle**: wired via `SetRxDutyCycle` MODE_RX, sized by the shared adapter math (same as SX126x). The earlier "broken, 23-40% loss" verdict was a window-sizing bug (over-sleep + no header budget), not a chip defect — default-off, HW-verify before production use. ### 5.5 Default Radio Parameters diff --git a/zephcore/Kconfig b/zephcore/Kconfig index c64efb0..8a345bc 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -485,27 +485,54 @@ config ZEPHCORE_LORA_RX_DUTY_CYCLE bool "Enable LoRa RX duty cycle power saving" default n help - Enable RX duty cycling for power saving on battery-powered devices. - Uses RadioLib's algorithm to calculate optimal timing based on SF/BW. + Enable RX duty cycling (chip-autonomous sniff mode) for power + saving on battery-powered devices. This sets the boot default; + it can be toggled at runtime via CLI "set rxduty on/off". - Disabled by default for all roles. Enable manually if you want - power savings and have verified acceptable packet loss/latency for - your environment and radio. - Duty cycle mode also resets AGC state after every received - packet via Calibrate(ALL), preventing the "near-far" gain-lock - problem where a strong signal desensitises the receiver. - Still disabled for LR1110 (cannot lock on mid-preamble, causes packet - loss) and LR2021 (untested — enable manually after verifying no - packet loss). Can be toggled at runtime via CLI. + Window timing is computed per SF/BW/preamble from primary-source + constraints (SX1261/2 datasheet sec 13.1.7, AN1200.36): the deaf + time per cycle (sleep + TCXO/PLL wake transition) never exceeds + the budget needed to guarantee one full detection window inside + the sender's preamble, and the post-detect timer budget + (2*rxPeriod + sleepPeriod) always covers preamble + header. + With a 32-symbol sender preamble (current MeshCore firmware at + SF<=8) this yields ~40-55% radio-off time with zero loss against + updated senders. Presets whose preamble is too short for a + guaranteed catch (16 symbols at SF>=9) automatically fall back to + continuous RX — no fake duty cycling. - The key insight is that LoRa preamble symbols are all identical, - so CAD/RX can detect preamble even if we wake mid-stream. The - algorithm ensures we always catch at least 8 symbols regardless - of when we wake within the preamble window. + Caveat for mixed meshes: nodes still on pre-preamble-32 firmware + (16 symbols at SF<=8) are only caught ~50% worst-phase. Keep + this off on infrastructure until the local mesh has converted. + Floods are usually re-heard via updated repeaters; direct + messages from legacy nodes are the real exposure. - Power savings: ~60-70% reduction in RX current (10-15mA to 3-5mA). + Duty cycle mode also resets AGC state on entry via + Calibrate(ALL), pins the boosted RX gain into the warm-start + retention list (datasheet sec 9.6, mandatory for SetRxDutyCycle), + and runs a parked-RX watchdog that re-arms the cycle after a + false preamble detect (inspect via CLI "get dc.restarts"). - Can be toggled at runtime via CLI "set rxduty on/off". + LR1110/LR20xx use the same chip-level SetRxDutyCycle MODE_RX + (identical preamble-detect-and-extend mechanism) sized by the same + adapter math; the LR1110's earlier "broken" status was a window- + sizing bug, not a chip defect. Both are wired but HW-unverified — + confirm no packet loss on a live board before trusting in + production. LR1110 has no infinite-park failure mode (its DC uses a + bounded 2*rx+sleep timeout), so it re-arms via the normal RX-timeout + path rather than the SX126x watchdog. + +config ZEPHCORE_LORA_DC_MIN_SYMBOLS + int "RX duty cycle: preamble symbols required for detection" + range 4 16 + default 8 + help + Minimum LoRa preamble symbols that must land inside one open RX + window for detection to be considered guaranteed. 8 is Semtech's + own figure for sniff mode (AN1200.36 sec 4); their time-synced + LoRaWAN stacks budget 6. Raise to 10-12 to trade radio-off time + for extra detection margin on very noisy sites. SF5/6 adds +4 + internally. Lowering below 8 risks missed packets at low SNR. config ZEPHCORE_APC bool "Adaptive Power Control (APC)" diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 44a4843..68e4fec 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -201,7 +201,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get loop.detect` | Loop detection level: `off`, `minimal`, `moderate`, or `strict` | | `get radio.rxgain` | RX gain boost: `0` or `1` | | `get rxduty` | RX duty cycle mode: `0` or `1` | -| `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter. High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery. Reset by `clear stats`. | +| `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | | `get public.key` | *(USB only)* Node's public key as hex | @@ -243,7 +243,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | | `set loop.detect ` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity | | `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost *(reboot required)* | -| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)* | +| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | | `set adc.multiplier ` | (0 = use board default) | Battery voltage ADC calibration multiplier | | `set prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 4a767a2..7a1b8de 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -533,6 +533,16 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } else { prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; } + + /* Offset 150: rx_duty_cycle (ZephCore extension). Absent in pre-existing + * files → leave the caller's in-RAM default (0 = continuous RX) untouched + * so the no-op past-EOF read can't force it on. */ + if (off < len) { + prefs.rx_duty_cycle = buf[off++]; + if (prefs.rx_duty_cycle > 1) { + prefs.rx_duty_cycle = 0; + } + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -605,7 +615,9 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 148: auto_shutdown_mv (ZephCore extension, 2 bytes LE) */ buf[off++] = prefs.auto_shutdown_mv & 0xFF; buf[off++] = (prefs.auto_shutdown_mv >> 8) & 0xFF; - /* Total: 150 bytes */ + /* Offset 150: rx_duty_cycle (ZephCore extension) */ + buf[off++] = prefs.rx_duty_cycle; + /* Total: 151 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index 68a86a9..f82f76c 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -79,4 +79,9 @@ void LR1110Radio::hwResetAGC() lr11xx_reset_agc(_dev); } +uint32_t LR1110Radio::hwWakeupTimeUs() +{ + return lr11xx_get_wakeup_time_us(_dev); +} + } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 5d9d63a..5497a10 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -28,6 +28,7 @@ protected: bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwResetAGC() override; + uint32_t hwWakeupTimeUs() override; }; } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 4ff59b5..3f2176c 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -23,8 +23,18 @@ static uint16_t preambleLengthForSF(uint8_t sf) return (sf <= 8) ? 32 : 16; } -static constexpr uint16_t RX_DUTY_RX_SYMBOLS = 13; -static constexpr uint16_t RX_DUTY_SLEEP_SYMBOLS = 3; +/* Minimum preamble symbols that must land inside one open duty-cycle RX + * window for guaranteed detection. 8 is Semtech's own figure for sniff + * mode (AN1200.36 §4: "8 symbols in LoRa make up the time required to + * ensure that the SX1261/2 detects a valid incoming packet"); their + * time-synced LoRaWAN stacks budget 6, so 8 already carries margin. + * SF5/6 need more symbols to reach sensitivity (RadioLib/LBM use 12). */ +static uint16_t rxDutyDetectSymbols(uint8_t sf) +{ + uint16_t d = CONFIG_ZEPHCORE_LORA_DC_MIN_SYMBOLS; + + return (sf >= 7) ? d : (uint16_t)(d + 4); +} /* ── Constructor ─────────────────────────────────────────────── */ @@ -39,6 +49,7 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), _rx_boost_enabled(true), _tx_power_reduction_db(0), + _dc_last_rx_us(0), _dc_last_sleep_us(0), _config_cached(false), _has_radio_override(false), _override_freq(0), _override_bw(0), @@ -421,32 +432,88 @@ void LoRaRadioBase::startReceive() int ret; if (_rx_duty_cycle_enabled) { - /* Fixed duty-cycle window for field validation. - * Keep build-time tunable via constants above. */ + /* Duty-cycle window sizing. All constraints primary-sourced + * (SX1261/2 DS rev 2.2 §13.1.7 + AN1200.36): + * + * 1. Catch — worst case is a preamble starting D−ε symbols + * before an RX window closes (detection aborts, must + * complete in the NEXT window), so the total deaf time per + * cycle (programmed sleep + wake transition) must satisfy + * sleep + trans ≤ (P − 2D − 1)·Tsym + * with 1 symbol margin for the chip's RC64k sleep timer. + * Stricter than AN1200.36's own "P ≥ sleep + D" model, + * which ignores the window-tail arrival case. + * 2. Complete — DS: "Tpreamble + Theader ≤ 2·rxPeriod + + * sleepPeriod". A preamble detected at its first symbols + * restarts the chip timer with 2R+S; that budget must cover + * the rest of the preamble + sync (4.25) + header (~8), + * rounded up to P + 14 symbols. The driver also sets + * StopTimerOnPreamble, but this sizing keeps packets safe + * under either documented timer behaviour. + * 3. Floor — rxPeriod ≥ (D+1)·Tsym so any single window can + * detect on its own. + * + * No viable sleep budget (short preamble, or the TCXO restart + * eats it) → honest fall-through to continuous RX. */ struct lora_modem_config cfg; buildModemConfig(cfg, false); - uint8_t sf = (uint8_t)cfg.datarate; - uint32_t bw_hz = bandwidth_to_hz(cfg.bandwidth); - float bw_khz = (float)bw_hz / 1000.0f; - if (bw_khz > 0.0f) { - uint32_t symbol_us = (uint32_t)((float)(1 << sf) * 1000.0f / bw_khz); - uint32_t rx_us = RX_DUTY_RX_SYMBOLS * symbol_us; - uint32_t sleep_us = RX_DUTY_SLEEP_SYMBOLS * symbol_us; + const uint8_t sf = (uint8_t)cfg.datarate; + const uint32_t bw_hz = bandwidth_to_hz(cfg.bandwidth); + const uint16_t P = cfg.preamble_len; + const uint16_t D = rxDutyDetectSymbols(sf); - ret = lora_recv_duty_cycle(_dev, - K_USEC(rx_us), - K_USEC(sleep_us), - rxCallbackStatic, this); - if (ret == 0) { - atomic_set(&_in_recv_mode, 1); - return; - } - if (ret != -ENOSYS) { - LOG_ERR("lora_recv_duty_cycle failed: %d", ret); + if (bw_hz > 0 && P > 2 * D + 1) { + const uint32_t sym_us = (uint32_t) + (((uint64_t)(1U << sf) * 1000000ULL) / bw_hz); + const uint32_t trans_us = hwWakeupTimeUs(); + const uint32_t deaf_us = + (uint32_t)(P - 2 * D - 1) * sym_us; + + if (deaf_us > trans_us + 2000) { + const uint32_t sleep_us = deaf_us - trans_us; + const uint32_t complete_us = + (uint32_t)(P + 14) * sym_us; + uint32_t rx_us = (uint32_t)(D + 1) * sym_us; + + if (complete_us > sleep_us && + rx_us < (complete_us - sleep_us + 1) / 2) { + rx_us = (complete_us - sleep_us + 1) / 2; + } + + if (rx_us != _dc_last_rx_us || + sleep_us != _dc_last_sleep_us) { + _dc_last_rx_us = rx_us; + _dc_last_sleep_us = sleep_us; + LOG_INF("rxduty: rx=%ums sleep=%ums trans=%ums (P=%u D=%u, off=%u%%)", + rx_us / 1000, sleep_us / 1000, + trans_us / 1000, P, D, + (uint32_t)(((uint64_t)sleep_us * 100) / + (rx_us + sleep_us + trans_us))); + } + + ret = lora_recv_duty_cycle(_dev, + K_USEC(rx_us), + K_USEC(sleep_us), + rxCallbackStatic, this); + if (ret == 0) { + atomic_set(&_in_recv_mode, 1); + return; + } + if (ret != -ENOSYS) { + LOG_ERR("lora_recv_duty_cycle failed: %d", ret); + } + /* Fall through to continuous RX */ + } else if (_dc_last_rx_us != UINT32_MAX) { + _dc_last_rx_us = UINT32_MAX; + LOG_INF("rxduty: wake transition %uus exceeds deaf budget %uus — continuous RX", + trans_us, deaf_us); } + } else if (_dc_last_rx_us != UINT32_MAX) { + _dc_last_rx_us = UINT32_MAX; + LOG_INF("rxduty: preamble %u too short for guaranteed catch (need >%u syms) — continuous RX", + P, 2 * D + 1); } - /* Fall through to normal recv_async */ } ret = lora_recv_async(_dev, rxCallbackStatic, this); diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index c1a4ae8..8fcdff3 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -112,6 +112,13 @@ protected: /** GPIO-only BUSY check (no SPI). Default false for chips without duty-cycle sleep. */ virtual bool hwIsChipBusy() { return false; } + /** Radio deaf time per duty-cycle wake transition (context restore + + * PLL lock + TCXO startup where fitted), in microseconds. Counts + * against the duty-cycle preamble-catch budget: per SX126x DS rev 2.2 + * §13.1.7 the TCXO startup delay is inserted between the sleep and RX + * periods, outside both. Default suits XTAL parts (LR2021). */ + virtual uint32_t hwWakeupTimeUs() { return 1500; } + /* Set to true by subclasses using the loramac-node driver backend. * Disables the direction-only fast path in configureTx()/configureRx(): * loramac-node calls Radio.SetTxConfig() and Radio.SetRxConfig() which @@ -164,6 +171,12 @@ protected: bool _rx_boost_enabled; int8_t _tx_power_reduction_db; + /* Last duty-cycle timing handed to the driver — used to log timing + * changes once at INF instead of on every RX restart. 0/0 = never + * computed; UINT32_MAX rx = continuous-RX fallback active. */ + uint32_t _dc_last_rx_us; + uint32_t _dc_last_sleep_us; + /* Config cache — skip redundant hwConfigure() */ struct lora_modem_config _last_cfg; bool _config_cached; diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index a803b9a..f4cd9e7 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -91,6 +91,11 @@ bool SX126xRadio::hwIsChipBusy() return sx126x_is_chip_busy(_dev); } +uint32_t SX126xRadio::hwWakeupTimeUs() +{ + return sx126x_get_wakeup_time_us(_dev); +} + uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const { return sx126x_get_dc_timeout_restarts(_dev); diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 9d25fa4..15d727a 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -31,6 +31,7 @@ protected: void hwSetRxBoost(bool enable) override; void hwResetAGC() override; bool hwIsChipBusy() override; + uint32_t hwWakeupTimeUs() override; }; } /* namespace mesh */ diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index ed942ef..8c7cb59 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -241,12 +241,6 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { LOG_INF("loadPrefs: upgraded prefs format (%d -> 296 bytes)", (int)entry.size); } - /* One-time migration: disable RX duty cycle if it was previously enabled. */ - if (prefs.rx_duty_cycle != 0) { - prefs.rx_duty_cycle = 0; - savePrefs(prefs); - LOG_INF("loadPrefs: migrated rx_duty_cycle to disabled"); - } return true; } diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index f4ddb1f..200b21c 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -259,7 +259,8 @@ should ONLY contain settings that can't be inferred from hardware: CONFIG_ZEPHCORE_UI_DISPLAY Auto from DT zephyr,display chosen CONFIG_SPI Auto from ZEPHCORE_RADIO_LR1110 CONFIG_NORDIC_QSPI_NOR Auto from DT nordic,qspi-nor node - CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE Auto: ON for companion+SX1262, OFF for repeater/LR1110 + CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE OFF by default for all roles (boot default only; + runtime toggle via CLI "set rxduty on/off") CONFIG_ESP_SPIRAM Auto: ON when DT psram0 size > 0 (ESP32-S/C5) CONFIG_ESP_SPIRAM_SIZE Auto: read from DT psram0 size by upstream Kconfig 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 b6ba6c6..7a6295e 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -79,10 +79,16 @@ struct lr11xx_data { volatile bool in_rx_mode; /* Extension features (duty cycle, boost) */ - bool rx_duty_cycle_enabled; /* unused — LR1110 always continuous RX */ + bool rx_duty_cycle_enabled; bool rx_boost_enabled; bool rx_boost_applied; /* RX boost register written to hardware */ + /* Stored duty-cycle timing from recv_duty_cycle() — the re-arm paths + * (start_rx / restart_rx) reuse these exact values, never recompute: + * window sizing is owned by the adapter layer (LoRaRadioBase). */ + uint32_t dc_rx_ms; + uint32_t dc_sleep_ms; + /* CAD state */ lora_cad_cb cad_cb; void *cad_user_data; @@ -305,18 +311,33 @@ static void lr11xx_start_rx(struct lr11xx_data *data, lr11xx_apply_modem_config(data, cfg, false); /* Apply RX boost — persistent register, only written once. - * Deferred from hw_init to here so radio is fully configured. */ - if (data->rx_boost_enabled && !data->rx_boost_applied) { + * Deferred from hw_init to here so radio is fully configured. + * In duty-cycle mode we force a re-apply on every (re)start: the + * chip's sleep-with-retention warm wakes are autonomous (no IRQ per + * wake, so software can't intervene mid-cycle), and we don't want to + * assume the boost register survives them — cheap insurance against + * the SX126x-style silent -3 dB loss across warm starts. */ + if (data->rx_boost_enabled && + (!data->rx_boost_applied || data->rx_duty_cycle_enabled)) { lr11xx_radio_cfg_rx_boosted(ctx, true); data->rx_boost_applied = true; } - /* Start continuous RX. - * 0xFFFFFF is the magic RTC-step value for continuous RX. - * Must use the raw RTC-step API — set_rx() converts from ms, - * which overflows uint32_t and gives a ~131 s timeout instead. - * LR1110 always uses continuous RX (duty cycle is broken). */ - lr11xx_radio_set_rx_with_timeout_in_rtc_step(ctx, 0xFFFFFF); + if (data->rx_duty_cycle_enabled && + data->dc_rx_ms != 0 && data->dc_sleep_ms != 0) { + /* Sniff mode: standard-RX listening with the chip's autonomous + * sleep/wake. MODE_RX uses the same preamble-detect-and-extend + * (2*rx + sleep) mechanism as the SX126x — catches a preamble + * mid-window. Timing is computed by the adapter. */ + lr11xx_radio_set_rx_duty_cycle(ctx, data->dc_rx_ms, + data->dc_sleep_ms, LR11XX_RADIO_RX_DUTY_CYCLE_MODE_RX); + } else { + /* Start continuous RX. + * 0xFFFFFF is the magic RTC-step value for continuous RX. + * Must use the raw RTC-step API — set_rx() converts from ms, + * which overflows uint32_t and gives a ~131 s timeout instead. */ + lr11xx_radio_set_rx_with_timeout_in_rtc_step(ctx, 0xFFFFFF); + } /* LR1110 firmware sets CMD_ERROR IRQ flag on several write commands * (SetModParams, SetSyncWord, SetRxBoosted, SetRx) across all @@ -345,9 +366,23 @@ static void lr11xx_restart_rx(struct lr11xx_data *data) void *ctx = &data->hal_ctx; lr11xx_system_clear_irq_status(ctx, LR11XX_SYSTEM_IRQ_ALL_MASK); - lr11xx_radio_set_rx_with_timeout_in_rtc_step(ctx, 0xFFFFFF); - /* RX boost persists through SetRx — no re-apply needed. */ + if (data->rx_duty_cycle_enabled && + data->dc_rx_ms != 0 && data->dc_sleep_ms != 0) { + /* Re-arm sniff mode with the stored timing (e.g. after RX_DONE + * or a false-preamble 2*rx+sleep timeout dropped the chip to + * standby). Re-apply boost — same warm-start caution as + * start_rx. */ + if (data->rx_boost_enabled) { + lr11xx_radio_cfg_rx_boosted(ctx, true); + } + lr11xx_radio_set_rx_duty_cycle(ctx, data->dc_rx_ms, + data->dc_sleep_ms, LR11XX_RADIO_RX_DUTY_CYCLE_MODE_RX); + } else { + lr11xx_radio_set_rx_with_timeout_in_rtc_step(ctx, 0xFFFFFF); + /* RX boost persists through SetRx — no re-apply needed. */ + } + data->in_rx_mode = true; } @@ -772,6 +807,61 @@ static int lr11xx_lora_recv_async(const struct device *dev, return 0; } +/* ── Driver API: recv_duty_cycle ─────────────────────────────────────── */ + +static int lr11xx_lora_recv_duty_cycle(const struct device *dev, + k_timeout_t rx_period, + k_timeout_t sleep_period, + lora_recv_cb cb, void *user_data) +{ + struct lr11xx_data *data = dev->data; + const struct lr11xx_config *cfg = dev->config; + + /* NULL cb = cancel (same as recv_async cancel) */ + if (cb == NULL) { + k_mutex_lock(&data->spi_mutex, K_FOREVER); + data->async_rx_cb = NULL; + data->async_rx_user_data = NULL; + data->rx_duty_cycle_enabled = false; + data->in_rx_mode = false; + k_mutex_unlock(&data->spi_mutex); + return 0; + } + + if (!data->configured) { + return -EINVAL; + } + + /* Explicit timing only — the adapter (LoRaRadioBase) owns the window + * sizing. No driver-side auto-compute. */ + if (K_TIMEOUT_EQ(rx_period, K_FOREVER) || + K_TIMEOUT_EQ(sleep_period, K_FOREVER)) { + LOG_ERR("recv_duty_cycle: explicit rx/sleep periods required"); + return -EINVAL; + } + + uint32_t rx_ms = k_ticks_to_ms_ceil32(rx_period.ticks); + uint32_t slp_ms = k_ticks_to_ms_ceil32(sleep_period.ticks); + if (rx_ms < 1) rx_ms = 1; + if (slp_ms < 1) slp_ms = 1; + + k_mutex_lock(&data->spi_mutex, K_FOREVER); + + data->async_rx_cb = cb; + data->async_rx_user_data = user_data; + data->dc_rx_ms = rx_ms; + data->dc_sleep_ms = slp_ms; + data->rx_duty_cycle_enabled = true; + + /* start_rx() reads rx_duty_cycle_enabled + dc_*_ms and issues + * SetRxDutyCycle(MODE_RX) with a forced boost re-apply. */ + lr11xx_start_rx(data, cfg); + + k_mutex_unlock(&data->spi_mutex); + LOG_INF("recv_duty_cycle: rx=%ums sleep=%ums", rx_ms, slp_ms); + return 0; +} + /* ── Driver API: recv (sync) ─────────────────────────────────────────── */ static int lr11xx_lora_recv(const struct device *dev, uint8_t *buf, @@ -818,6 +908,21 @@ bool lr11xx_is_receiving(const struct device *dev) LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID)) != 0; } +uint32_t lr11xx_get_wakeup_time_us(const struct device *dev) +{ + const struct lr11xx_config *cfg = dev->config; + /* Context restore + PLL lock (~1.5 ms) plus the TCXO startup delay + * where fitted. Per the LR11xx SetRxDutyCycle model the wake + * transition is deaf time between sleep and RX, counting against the + * adapter's preamble-catch budget — same accounting as the SX126x. */ + uint32_t us = 1500; + + if (cfg->tcxo_voltage_mv > 0) { + us += cfg->tcxo_startup_delay_ms * 1000U; + } + return us; +} + void lr11xx_set_rx_boost(const struct device *dev, bool enable) { struct lr11xx_data *data = dev->data; @@ -1203,7 +1308,12 @@ static DEVICE_API(lora, lr11xx_lora_api) = { .recv_async = lr11xx_lora_recv_async, .cad = lr11xx_lora_cad, .cad_async = lr11xx_lora_cad_async, - /* .recv_duty_cycle = NULL — LR1110 duty cycle broken */ + /* MODE_RX sniff: same preamble-detect-and-extend (2*rx+sleep) as the + * SX126x. The earlier "broken" verdict was a window-sizing bug + * (over-sleep + no header budget), not a chip defect — now sized by + * the shared adapter math. Default-off via prefs; HW-verify on a + * live LR1110 before trusting in production. */ + .recv_duty_cycle = lr11xx_lora_recv_duty_cycle, }; #define LR11XX_INIT(n) \ diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h index 3bd06cc..e518701 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h @@ -50,6 +50,19 @@ bool lr11xx_is_receiving(const struct device *dev); */ void lr11xx_set_rx_boost(const struct device *dev, bool enable); +/** + * @brief Radio deaf time per duty-cycle wake transition, in microseconds + * + * Context restore + PLL lock plus the DTS-configured TCXO startup delay + * where fitted. Used by the adapter (LoRaRadioBase) to size duty-cycle + * windows so the wake transition is charged against the preamble-catch + * budget — same accounting as the SX126x. + * + * @param dev LoRa device + * @return Transition deaf time in microseconds + */ +uint32_t lr11xx_get_wakeup_time_us(const struct device *dev); + /** * @brief Get a random number from the radio * 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 4bdccf9..766b46d 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -86,6 +86,13 @@ struct lr20xx_data { bool rx_boost_enabled; bool rx_boost_applied; + /* Stored duty-cycle timing from recv_duty_cycle() — re-arm paths + * must reuse these exact values, never recompute: the window sizing + * (detection budget, datasheet completion rule, wake transition) is + * owned by the adapter layer. */ + uint32_t dc_rx_ms; + uint32_t dc_sleep_ms; + /* CAD state */ lora_cad_cb cad_cb; void *cad_user_data; @@ -474,77 +481,28 @@ static void lr20xx_apply_modem_config(struct lr20xx_data *data, /* ── RX duty cycle ──────────────────────────────────────────────────── */ -/* Minimum preamble symbols needed for detection (same as SX126x) */ -#define LR20XX_DC_MIN_SYMBOLS_SF7_PLUS 8 -#define LR20XX_DC_MIN_SYMBOLS_SF6_LESS 12 -#define LR20XX_DC_TCXO_DELAY_US 1000 - /** - * Compute duty cycle timing from current modem config and issue the - * SetRxDutyCycle command. Returns true on success, false if the - * preamble is too short (falls back to continuous RX). - * - * Mirrors the SX126x symbol-based calculation: sleep through - * (preamble_len - min_symbols - 2) symbols, wake for the remainder - * plus margin. + * Re-issue the SetRxDutyCycle command with the timing stored by + * lr20xx_lora_recv_duty_cycle(). Returns true on success, false if no + * timing has been provided yet (falls back to continuous RX). */ static bool lr20xx_apply_rx_duty_cycle(struct lr20xx_data *data) { void *ctx = &data->hal_ctx; - struct lora_modem_config *mc = &data->modem_cfg; - uint8_t sf = (uint8_t)mc->datarate; - float bw_khz = bw_enum_to_khz(mc->bandwidth); - uint16_t preamble_len = mc->preamble_len; - - uint16_t min_symbols = (sf >= 7) ? LR20XX_DC_MIN_SYMBOLS_SF7_PLUS - : LR20XX_DC_MIN_SYMBOLS_SF6_LESS; - - int16_t sleep_symbols = (int16_t)preamble_len - (int16_t)min_symbols; - if (sleep_symbols <= 0) { - LOG_WRN("Preamble too short for duty cycle " - "(need >%d symbols, have %d) — continuous RX", - min_symbols, preamble_len); + if (data->dc_rx_ms == 0 || data->dc_sleep_ms == 0) { + LOG_WRN("No duty-cycle timing stored — continuous RX"); data->rx_duty_cycle_enabled = false; lr20xx_radio_common_set_rx_with_timeout_in_rtc_step( ctx, 0xFFFFFF); return false; } - /* symbol_us = 2^SF / BW_kHz * 1000 */ - uint32_t symbol_us = (uint32_t)((float)(1 << sf) * 1000.0f / bw_khz); + lr20xx_radio_common_set_rx_duty_cycle(ctx, data->dc_rx_ms, + data->dc_sleep_ms, LR20XX_RADIO_COMMON_RX_DUTY_CYCLE_MODE_RX); - /* Shave 2 symbols off sleep for timing margin */ - int16_t sleep_symbols_safe = sleep_symbols - 2; - if (sleep_symbols_safe < 1) { - sleep_symbols_safe = 1; - } - uint32_t sleep_period_us = (uint16_t)sleep_symbols_safe * symbol_us; - - uint32_t preamble_total_us = (preamble_len + 1) * symbol_us; - int32_t wake_calc1 = ((int32_t)preamble_total_us - - ((int32_t)sleep_period_us - - LR20XX_DC_TCXO_DELAY_US)) / 2; - uint32_t wake_calc2 = (min_symbols + 1) * symbol_us; - - uint32_t wake_period_us = - (wake_calc1 > 0 && (uint32_t)wake_calc1 > wake_calc2) - ? (uint32_t)wake_calc1 : wake_calc2; - - uint32_t rx_ms = (wake_period_us + 999) / 1000; /* round up */ - uint32_t slp_ms = (sleep_period_us + 999) / 1000; - - if (rx_ms < 1) { - rx_ms = 1; - } - if (slp_ms < 1) { - slp_ms = 1; - } - - lr20xx_radio_common_set_rx_duty_cycle(ctx, rx_ms, slp_ms, - LR20XX_RADIO_COMMON_RX_DUTY_CYCLE_MODE_RX); - - LOG_DBG("RX duty cycle: SF%d rx=%ums sleep=%ums", sf, rx_ms, slp_ms); + LOG_DBG("RX duty cycle re-armed: rx=%ums sleep=%ums", + data->dc_rx_ms, data->dc_sleep_ms); return true; } @@ -1311,6 +1269,13 @@ static int lr20xx_lora_recv_duty_cycle(const struct device *dev, return -EINVAL; } + /* Explicit timing only — the adapter owns the window sizing. */ + if (K_TIMEOUT_EQ(rx_period, K_FOREVER) || + K_TIMEOUT_EQ(sleep_period, K_FOREVER)) { + LOG_ERR("recv_duty_cycle: explicit rx/sleep periods required"); + return -EINVAL; + } + k_mutex_lock(&data->spi_mutex, K_FOREVER); data->async_rx_cb = cb; @@ -1328,6 +1293,10 @@ static int lr20xx_lora_recv_duty_cycle(const struct device *dev, if (rx_ms < 1) rx_ms = 1; if (slp_ms < 1) slp_ms = 1; + /* Store for the re-arm paths (lr20xx_start_rx / lr20xx_restart_rx) + * so every re-entry uses exactly this timing. */ + data->dc_rx_ms = rx_ms; + data->dc_sleep_ms = slp_ms; data->rx_duty_cycle_enabled = true; lr20xx_radio_common_set_rx_duty_cycle(ctx, rx_ms, slp_ms, LR20XX_RADIO_COMMON_RX_DUTY_CYCLE_MODE_RX); diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index 63cec97..5ed3a7e 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -88,6 +88,20 @@ void sx126x_apply_heltec_reg_patch(const struct device *dev); */ void sx126x_reset_agc(const struct device *dev); +/** + * @brief Radio deaf time per duty-cycle wake transition, in microseconds + * + * Context save/restore + PLL lock (~1.5 ms) plus the DTS-configured TCXO + * startup delay where fitted. Per datasheet rev 2.2 §13.1.7, the TCXO + * delay is inserted between the sleep and RX periods of every duty-cycle + * wake — it counts against the preamble-catch budget, outside both + * programmed periods. Used by the adapter to size duty-cycle windows. + * + * @param dev LoRa device + * @return Transition deaf time in microseconds + */ +uint32_t sx126x_get_wakeup_time_us(const struct device *dev); + /** * @brief Get duty-cycle preamble false-positive counter * diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 49bfa38..fa2d63f 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -68,7 +68,7 @@ index 17689720dd2..09982dc2e70 100644 return -EINVAL; } diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c -index 30243ba5dc7..d2339ee8780 100644 +index 30243ba5dc7..69eefee6e0f 100644 --- a/drivers/lora/native/sx126x/sx126x.c +++ b/drivers/lora/native/sx126x/sx126x.c @@ -6,14 +6,21 @@ @@ -173,21 +173,22 @@ index 30243ba5dc7..d2339ee8780 100644 } static int sx126x_set_sync_word(const struct device *dev, bool public_network) -@@ -247,6 +311,13 @@ static int sx126x_set_rx_gain(const struct device *dev, bool boosted) +@@ -247,6 +311,14 @@ 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; -+ /* Retention NOT used: without writing SX126X_REG_RX_GAIN_RETENTION_0, -+ * the chip resets 0x08AC to power-saving (0x94) on every SetRx command. -+ * This clears any residual AGC state accumulated during strong-signal -+ * reception (near-far problem: strong first relay locks AGC, weaker -+ * second relay missed). sx126x_restart_rx() and hwStartReceive() both -+ * re-apply the desired gain after SetRx, after BUSY goes low and before -+ * any preamble can arrive (~3.4 ms RC startup window). */ ++ /* Host-driven RX paths do not rely on retention: the chip resets ++ * 0x08AC to power-saving (0x94) on every SetRx command, and ++ * sx126x_restart_rx() / recv_async re-apply the desired gain right ++ * after SetRx (after BUSY goes low, before any preamble can arrive). ++ * Duty-cycle mode is different: its sleep->RX wakes are chip-internal ++ * warm starts the host never sees, so the DC entry points ++ * additionally pin 0x08AC into the warm-start retention list via ++ * sx126x_retain_rx_gain() (DS sec 9.6: mandatory for SetRxDutyCycle). */ return sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &val, 1); } -@@ -296,7 +367,7 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -296,7 +368,7 @@ static int sx126x_get_packet_status(const struct device *dev, uint8_t buf[3]; int ret; @@ -196,7 +197,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (ret == 0) { /* RSSI is -value/2 dBm */ *rssi = -((int16_t)buf[0] >> 1); -@@ -367,6 +438,26 @@ static int sx126x_chip_init(const struct device *dev) +@@ -367,6 +439,26 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -223,7 +224,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* Set packet type to LoRa */ ret = sx126x_set_packet_type(dev, SX126X_PACKET_TYPE_LORA); if (ret < 0) { -@@ -374,10 +465,23 @@ static int sx126x_chip_init(const struct device *dev) +@@ -374,10 +466,23 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -250,7 +251,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (ret < 0) { LOG_ERR("Set IRQ params failed: %d", ret); return ret; -@@ -398,7 +502,7 @@ static void sx126x_dio1_callback(const struct device *dev) +@@ -398,7 +503,7 @@ static void sx126x_dio1_callback(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -259,7 +260,7 @@ index 30243ba5dc7..d2339ee8780 100644 } static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx) -@@ -406,7 +510,20 @@ static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx) +@@ -406,7 +511,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); @@ -281,10 +282,44 @@ index 30243ba5dc7..d2339ee8780 100644 sx126x_hal_set_rf_switch(dev, enable, tx); } } -@@ -455,6 +572,98 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) +@@ -455,6 +573,215 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) } #endif /* CONFIG_PM_DEVICE */ ++/* -- Duty-cycle support helpers -- */ ++ ++/* Watchdog sampling period. Must exceed the longest legitimate ++ * preamble-detect -> header gap at any supported preset so that two ++ * consecutive parked sightings can never be one real packet (worst ++ * case SF12/BW62.5, 16-sym preamble: ~1.9 s). */ ++#define SX126X_DC_WATCHDOG_MS 3000 ++ ++/* DS sec 9.6: the RX gain register (0x08AC) is not part of the warm-start ++ * retention memory. RxDutyCycle sleep->RX wakes are chip-internal warm ++ * starts -- without adding 0x08AC to the retention list, every window ++ * after the first sleep reverts to power-saving gain (-3 dB sensitivity) ++ * with no way for the host to intervene. The datasheet calls this ++ * "mandatory when using the SetRxDutyCycle method, to keep on using the ++ * Rx Boosted gain". Layout: entry count, then big-endian register ++ * address per entry. */ ++static void sx126x_retain_rx_gain(const struct device *dev) ++{ ++ uint8_t retention[3] = { 0x01, 0x08, 0xAC }; ++ ++ sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN_RETENTION_0, ++ retention, 3); ++} ++ ++/* (Re)arm the parked-RX watchdog and forget any parked evidence -- called ++ * from every duty-cycle (re)start site, which all bulk-clear the IRQ ++ * register first. */ ++static void sx126x_dc_watchdog_kick(struct sx126x_data *data) ++{ ++ data->dc_watchdog_strikes = 0; ++ k_work_reschedule_for_queue(&data->dio1_wq, &data->dc_watchdog_work, ++ K_MSEC(SX126X_DC_WATCHDOG_MS)); ++} ++ +/* -- Lightweight RX restart -- */ + +/* Restart RX as fast as possible -- used for RX->RX transitions in the @@ -341,12 +376,20 @@ index 30243ba5dc7..d2339ee8780 100644 + sx126x_hal_write_cmd(dev, SX126X_CMD_STOP_TIMER_ON_PREAMBLE, + &stop_on_preamble, 1); + ++ /* Keep boosted RX gain alive across the chip-internal ++ * sleep->RX wakes (DS sec 9.6, mandatory for DC mode). ++ * Re-issued here as cheap insurance against Calibrate(ALL) ++ * above disturbing the list. */ ++ sx126x_retain_rx_gain(dev); ++ + /* Re-apply duty cycle with stored timing */ + uint8_t dc_buf[6]; + sys_put_be24(data->dc_rx_time, &dc_buf[0]); + sys_put_be24(data->dc_sleep_time, &dc_buf[3]); + sx126x_hal_write_cmd(dev, SX126X_CMD_SET_RX_DUTY_CYCLE, + dc_buf, 6); ++ ++ sx126x_dc_watchdog_kick(data); + } else { + /* Non-DC continuous RX: plain SetRx(continuous), no symbol + * timer. An earlier draft armed SetLoRaSymbNumTimeout here for @@ -376,11 +419,86 @@ index 30243ba5dc7..d2339ee8780 100644 + sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &gain, 1); + return 0; +} ++ ++/* Parked-RX watchdog (see struct field comment). Runs on the dedicated ++ * DIO1 work queue so it serialises with the IRQ handler and restart ++ * paths. Self-terminating: returns without re-arming when duty-cycle RX ++ * is no longer active -- every DC (re)start kicks it again. */ ++static void sx126x_dc_watchdog_handler(struct k_work *work) ++{ ++ struct k_work_delayable *dwork = k_work_delayable_from_work(work); ++ struct sx126x_data *data = CONTAINER_OF(dwork, struct sx126x_data, ++ dc_watchdog_work); ++ const struct device *dev = data->dev; ++ uint16_t irq_status = 0; ++ bool parked = false; ++ ++ if (!data->rx_duty_cycle_enabled || ++ atomic_get(&data->state) != SX126X_STATE_RX) { ++ return; ++ } ++ ++ /* Legit packet mid-decode (HEADER_VALID latched): check again later. */ ++ if (data->rx_packet_active) { ++ data->dc_watchdog_strikes = 0; ++ goto rearm; ++ } ++ ++ /* BUSY high = duty-cycle sleep phase = cycling normally. */ ++ if (sx126x_hal_is_busy(dev)) { ++ data->dc_watchdog_strikes = 0; ++ goto rearm; ++ } ++ ++ if (k_mutex_lock(&data->lock, K_NO_WAIT) != 0) { ++ /* Another operation owns the chip right now -- not parked. */ ++ data->dc_watchdog_strikes = 0; ++ goto rearm; ++ } ++ ++ /* Re-check BUSY under the lock (same pattern as get_rssi_inst): a ++ * sleep transition between the check above and the SPI read below ++ * would stall the HAL and force-wake the chip. */ ++ if (!sx126x_hal_is_busy(dev) && ++ sx126x_get_irq_status(dev, &irq_status) == 0) { ++ parked = (irq_status & SX126X_IRQ_PREAMBLE_DETECTED) && ++ !(irq_status & SX126X_IRQ_HEADER_VALID); ++ } ++ ++ if (!parked) { ++ data->dc_watchdog_strikes = 0; ++ k_mutex_unlock(&data->lock); ++ goto rearm; ++ } ++ ++ if (++data->dc_watchdog_strikes < 2) { ++ /* First sighting: could be a real preamble whose header is ++ * still in flight. A second sighting one full period later ++ * cannot be the same packet (period > worst-case ++ * preamble + sync + header airtime). */ ++ k_mutex_unlock(&data->lock); ++ goto rearm; ++ } ++ ++ /* Two strikes: a false preamble detect parked the chip in full RX ++ * (StopTimerOnPreamble behaviour, AN1200.36 sec 3). Nothing was ++ * lost -- parked RX still receives -- but the duty cycle stopped. ++ * Re-arm it. Shares the dc_timeout_restarts stat: both paths mean ++ * "false preamble cost extra RX-on time". */ ++ atomic_inc(&data->dc_timeout_restarts); ++ (void)sx126x_restart_rx(dev, data); ++ k_mutex_unlock(&data->lock); ++ return; /* restart_rx re-kicked the watchdog */ ++ ++rearm: ++ k_work_schedule_for_queue(&data->dio1_wq, &data->dc_watchdog_work, ++ K_MSEC(SX126X_DC_WATCHDOG_MS)); ++} + static int sx126x_set_sleep(const struct device *dev) { struct sx126x_data *data = dev->data; -@@ -533,8 +742,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -533,8 +860,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; @@ -397,7 +515,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* Get received packet info */ ret = sx126x_get_rx_buffer_status(dev, &payload_len, &offset); if (ret < 0) { -@@ -544,9 +761,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -544,9 +879,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta /* Get signal quality */ sx126x_get_packet_status(dev, &result.rssi, &result.snr); @@ -427,7 +545,7 @@ index 30243ba5dc7..d2339ee8780 100644 result.status = -EIO; } else { /* Read payload into shared buffer */ -@@ -564,20 +798,34 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -564,20 +916,34 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta } } @@ -474,7 +592,7 @@ index 30243ba5dc7..d2339ee8780 100644 } } else { /* Sync mode */ -@@ -589,8 +837,69 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -589,8 +955,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; @@ -544,7 +662,7 @@ index 30243ba5dc7..d2339ee8780 100644 sx126x_set_sleep(dev); if (data->tx_async_signal != NULL) { -@@ -633,10 +942,46 @@ static void sx126x_irq_work_handler(struct k_work *work) +@@ -633,10 +1060,46 @@ static void sx126x_irq_work_handler(struct k_work *work) sx126x_handle_irq_rx_done(dev, irq_status); } @@ -591,7 +709,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* 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 +1027,7 @@ static int sx126x_lora_config(const struct device *dev, +@@ -682,7 +1145,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, @@ -600,7 +718,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (ret < 0) { goto out; } -@@ -698,6 +1043,29 @@ static int sx126x_lora_config(const struct device *dev, +@@ -698,6 +1161,29 @@ static int sx126x_lora_config(const struct device *dev, goto out; } @@ -630,7 +748,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* Set sync word */ ret = sx126x_set_sync_word(dev, config->public_network); if (ret < 0) { -@@ -721,6 +1089,8 @@ out: +@@ -721,6 +1207,8 @@ out: return ret; } @@ -639,7 +757,7 @@ index 30243ba5dc7..d2339ee8780 100644 static int sx126x_lora_send_async(const struct device *dev, uint8_t *data_buf, uint32_t data_len, struct k_poll_signal *async) -@@ -738,11 +1108,27 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -738,11 +1226,27 @@ static int sx126x_lora_send_async(const struct device *dev, return -EINVAL; } @@ -669,7 +787,7 @@ index 30243ba5dc7..d2339ee8780 100644 k_mutex_lock(&data->lock, K_FOREVER); ret = sx126x_ensure_ready(dev); -@@ -752,6 +1138,58 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -752,6 +1256,58 @@ static int sx126x_lora_send_async(const struct device *dev, return ret; } @@ -728,7 +846,7 @@ index 30243ba5dc7..d2339ee8780 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,6 +1215,29 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,6 +1333,29 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); @@ -758,7 +876,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* Start transmission with 10 second timeout */ ret = sx126x_set_tx(dev, 10000); if (ret < 0) { -@@ -847,6 +1308,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, +@@ -847,6 +1426,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, } data->rx_cb = NULL; @@ -767,7 +885,7 @@ index 30243ba5dc7..d2339ee8780 100644 k_msgq_purge(&data->rx_msgq); /* Set packet parameters for variable length reception */ -@@ -918,6 +1381,8 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -918,6 +1499,8 @@ static int sx126x_lora_recv_async(const struct device *dev, /* Stop async reception */ data->rx_cb = NULL; data->rx_cb_user_data = NULL; @@ -776,7 +894,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { sx126x_set_standby(dev, SX126X_STANDBY_RC); sx126x_set_sleep(dev); -@@ -932,6 +1397,19 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -932,6 +1515,19 @@ static int sx126x_lora_recv_async(const struct device *dev, return -EINVAL; } @@ -796,7 +914,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { LOG_ERR("Busy"); k_mutex_unlock(&data->lock); -@@ -945,8 +1423,18 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -945,8 +1541,18 @@ static int sx126x_lora_recv_async(const struct device *dev, return ret; } @@ -815,7 +933,7 @@ index 30243ba5dc7..d2339ee8780 100644 /* Set packet parameters */ ret = sx126x_set_packet_params(dev, -@@ -959,6 +1447,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -959,6 +1565,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; @@ -823,7 +941,7 @@ index 30243ba5dc7..d2339ee8780 100644 sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); return ret; -@@ -971,11 +1460,21 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -971,11 +1578,21 @@ static int sx126x_lora_recv_async(const struct device *dev, ret = sx126x_set_rx(dev, 0); if (ret < 0) { data->rx_cb = NULL; @@ -845,7 +963,7 @@ index 30243ba5dc7..d2339ee8780 100644 k_mutex_unlock(&data->lock); return 0; } -@@ -1051,7 +1550,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1051,7 +1668,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, @@ -854,7 +972,7 @@ index 30243ba5dc7..d2339ee8780 100644 if (ret < 0) { sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); -@@ -1083,14 +1582,602 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1083,14 +1700,595 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, return 0; } @@ -1053,6 +1171,23 @@ index 30243ba5dc7..d2339ee8780 100644 + atomic_set(&data->dc_timeout_restarts, 0); +} + ++uint32_t sx126x_get_wakeup_time_us(const struct device *dev) ++{ ++ const struct sx126x_hal_config *config = dev->config; ++ /* Context restore + PLL lock: "around 1 ms" per DS fig 13-2 ("not ++ * accurate and may vary"), plus 500 us margin for the save side. */ ++ uint32_t us = 1500; ++ ++ /* DS rev 2.2 sec 13.1.7 note: with a chip-controlled TCXO, the ++ * programmed startup delay is inserted between the sleep and RX ++ * periods of every duty-cycle wake -- pure deaf time, outside both ++ * programmed periods. */ ++ if (config->dio3_tcxo_enable) { ++ us += config->tcxo_startup_delay_ms * 1000U; ++ } ++ return us; ++} ++ +void sx126x_apply_heltec_reg_patch(const struct device *dev) +{ + struct sx126x_data *data = dev->data; @@ -1360,55 +1495,25 @@ index 30243ba5dc7..d2339ee8780 100644 + + uint32_t rx_time, sleep_time; + ++ /* Explicit timing only. The window sizing (detection budget, ++ * datasheet Tpreamble+Theader <= 2*rxPeriod+sleepPeriod rule, TCXO ++ * transition accounting) is owned by the caller -- the adapter layer ++ * knows the mesh preamble policy, the driver just programs periods. */ + if (K_TIMEOUT_EQ(rx_period, K_FOREVER) || + K_TIMEOUT_EQ(sleep_period, K_FOREVER)) { -+ /* Auto-compute from modem config (RadioLib algorithm) */ -+ struct lora_modem_config *mc = &data->config; -+ uint8_t sf = (uint8_t)mc->datarate; -+ uint32_t bw_hz = bandwidth_to_hz(mc->bandwidth); -+ float bw_khz = (float)bw_hz / 1000.0f; -+ uint16_t preamble_len = mc->preamble_len; -+ -+ uint16_t min_symbols = (sf >= 7) ? 8 : 12; -+ int16_t sleep_symbols = (int16_t)preamble_len - -+ (int16_t)min_symbols; -+ if (sleep_symbols <= 0) { -+ LOG_WRN("Preamble too short for duty cycle " -+ "(need >%d, have %d)", -+ min_symbols, preamble_len); -+ data->rx_cb = NULL; -+ atomic_inc(&data->rx_cb_gen); -+ sx126x_set_sleep(dev); -+ k_mutex_unlock(&data->lock); -+ atomic_set(&data->state, SX126X_REST_STATE); -+ return -EINVAL; -+ } -+ -+ uint32_t symbol_us = (uint32_t)((float)(1 << sf) * -+ 1000.0f / bw_khz); -+ -+ int16_t sleep_symbols_safe = sleep_symbols - 2; -+ if (sleep_symbols_safe < 1) sleep_symbols_safe = 1; -+ uint32_t sleep_us = (uint16_t)sleep_symbols_safe * symbol_us; -+ -+ uint32_t preamble_us = (preamble_len + 1) * symbol_us; -+ int32_t wake1 = ((int32_t)preamble_us - -+ ((int32_t)sleep_us - 1000)) / 2; -+ uint32_t wake2 = (min_symbols + 1) * symbol_us; -+ uint32_t rx_us = (wake1 > 0 && (uint32_t)wake1 > wake2) -+ ? (uint32_t)wake1 : wake2; -+ -+ /* Convert us to 15.625us steps */ -+ rx_time = (rx_us * 64) / 1000; -+ sleep_time = (sleep_us * 64) / 1000; -+ } else { -+ /* Explicit timing from caller */ -+ uint32_t rx_us = k_ticks_to_us_ceil32(rx_period.ticks); -+ uint32_t slp_us = k_ticks_to_us_ceil32(sleep_period.ticks); -+ rx_time = (rx_us * 64) / 1000; -+ sleep_time = (slp_us * 64) / 1000; ++ LOG_ERR("recv_duty_cycle: explicit rx/sleep periods required"); ++ data->rx_cb = NULL; ++ atomic_inc(&data->rx_cb_gen); ++ sx126x_set_sleep(dev); ++ k_mutex_unlock(&data->lock); ++ atomic_set(&data->state, SX126X_REST_STATE); ++ return -EINVAL; + } + ++ /* Convert us to 15.625us steps */ ++ rx_time = (k_ticks_to_us_ceil32(rx_period.ticks) * 64) / 1000; ++ sleep_time = (k_ticks_to_us_ceil32(sleep_period.ticks) * 64) / 1000; ++ + if (rx_time < 64) rx_time = 64; + if (sleep_time < 64) sleep_time = 64; + @@ -1425,16 +1530,22 @@ index 30243ba5dc7..d2339ee8780 100644 + sx126x_hal_write_cmd(dev, SX126X_CMD_STOP_TIMER_ON_PREAMBLE, + &stop_on_preamble, 1); + ++ /* Apply RX gain, then pin it into the warm-start retention list so the ++ * chip-internal sleep->RX wakes restore it (DS sec 9.6: mandatory for ++ * SetRxDutyCycle -- without this every window after the first sleep ++ * listens at power-saving gain, a silent -3 dB sensitivity loss). */ ++ uint8_t gain = data->rx_boost_enabled ? SX126X_RX_GAIN_BOOSTED ++ : SX126X_RX_GAIN_POWER_SAVING; ++ sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &gain, 1); ++ sx126x_retain_rx_gain(dev); ++ + uint8_t buf[6]; + + sys_put_be24(rx_time, &buf[0]); + sys_put_be24(sleep_time, &buf[3]); + sx126x_hal_write_cmd(dev, SX126X_CMD_SET_RX_DUTY_CYCLE, buf, 6); + -+ /* Apply RX gain */ -+ uint8_t gain = data->rx_boost_enabled ? SX126X_RX_GAIN_BOOSTED -+ : SX126X_RX_GAIN_POWER_SAVING; -+ sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &gain, 1); ++ sx126x_dc_watchdog_kick(data); + + k_mutex_unlock(&data->lock); + @@ -1464,7 +1575,7 @@ index 30243ba5dc7..d2339ee8780 100644 }; #ifdef CONFIG_PM_DEVICE -@@ -1112,6 +2199,7 @@ static int sx126x_pm_action(const struct device *dev, +@@ -1112,6 +2310,7 @@ static int sx126x_pm_action(const struct device *dev, static int sx126x_init(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1472,7 +1583,7 @@ index 30243ba5dc7..d2339ee8780 100644 int ret; /* Initialize data structures */ -@@ -1121,9 +2209,26 @@ static int sx126x_init(const struct device *dev) +@@ -1121,9 +2320,29 @@ 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); @@ -1490,6 +1601,9 @@ index 30243ba5dc7..d2339ee8780 100644 + * sx126x_set_rx_boost() extension API. */ + data->rx_boost_enabled = config->rx_boosted; + atomic_set(&data->dc_timeout_restarts, 0); ++ k_work_init_delayable(&data->dc_watchdog_work, ++ sx126x_dc_watchdog_handler); ++ data->dc_watchdog_strikes = 0; + + /* Start dedicated DIO1 work queue */ + k_work_queue_start(&data->dio1_wq, sx126x_dio1_wq_stack, @@ -1500,10 +1614,10 @@ index 30243ba5dc7..d2339ee8780 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..880b3240d22 100644 +index 9dbf3f26586..2598b56d048 100644 --- a/drivers/lora/native/sx126x/sx126x.h +++ b/drivers/lora/native/sx126x/sx126x.h -@@ -56,13 +56,58 @@ struct sx126x_data { +@@ -56,13 +56,69 @@ struct sx126x_data { /* Async RX callback */ lora_recv_cb rx_cb; void *rx_cb_user_data; @@ -1545,6 +1659,17 @@ index 9dbf3f26586..880b3240d22 100644 + 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) */ + ++ /* Parked-RX watchdog. StopTimerOnPreamble=1 means a false preamble ++ * detect parks the chip in full RX "until the next received frame" ++ * (AN1200.36 sec 3) with no IRQ fired -- nothing is lost (parked RX ++ * still receives) but duty cycling silently stops costing full RX ++ * current. The watchdog samples the IRQ register every few seconds ++ * and re-arms the duty cycle after two consecutive sightings of ++ * PREAMBLE_DETECTED without HEADER_VALID (one packet can never span ++ * two sightings; restart sites and terminal IRQs clear the bit). */ ++ struct k_work_delayable dc_watchdog_work; ++ uint8_t dc_watchdog_strikes; ++ + /* 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).