Make Rxdutycycling Great Again

This commit is contained in:
liquidraver
2026-06-13 08:23:01 +02:00
parent 061a65a7f0
commit bf71542751
17 changed files with 564 additions and 207 deletions
+1 -1
View File
@@ -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
+44 -17
View File
@@ -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)"
+2 -2
View File
@@ -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 <mode>` | 0, 1, or 2 | Path hashing algorithm |
| `set loop.detect <mode>` | `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 <mult>` | (0 = use board default) | Battery voltage ADC calibration multiplier |
| `set prv.key <hex>` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* |
@@ -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'",
+5
View File
@@ -79,4 +79,9 @@ void LR1110Radio::hwResetAGC()
lr11xx_reset_agc(_dev);
}
uint32_t LR1110Radio::hwWakeupTimeUs()
{
return lr11xx_get_wakeup_time_us(_dev);
}
} /* namespace mesh */
+1
View File
@@ -28,6 +28,7 @@ protected:
bool hwIsReceiving() override;
void hwSetRxBoost(bool enable) override;
void hwResetAGC() override;
uint32_t hwWakeupTimeUs() override;
};
} /* namespace mesh */
+89 -22
View File
@@ -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);
+13
View File
@@ -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;
+5
View File
@@ -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);
+1
View File
@@ -31,6 +31,7 @@ protected:
void hwSetRxBoost(bool enable) override;
void hwResetAGC() override;
bool hwIsChipBusy() override;
uint32_t hwWakeupTimeUs() override;
};
} /* namespace mesh */
-6
View File
@@ -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;
}
+2 -1
View File
@@ -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
@@ -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) \
@@ -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
*
@@ -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);
@@ -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
*
@@ -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).