Skip to content

Added RX duty-cycle power saving support#11

Open
Dom4n wants to merge 9 commits into
IoTThinks:PowerSaving-v16from
Dom4n:rx-powersaving
Open

Added RX duty-cycle power saving support#11
Dom4n wants to merge 9 commits into
IoTThinks:PowerSaving-v16from
Dom4n:rx-powersaving

Conversation

@Dom4n

@Dom4n Dom4n commented Jul 8, 2026

Copy link
Copy Markdown

Description

This PR adds RX duty-cycle / power saving support for RadioLib-based radios, with CLI configuration and persisted node preferences.

What it does

  • Adds a new CLI command for RX power saving:

    set radio.rxps ...

    Supported modes:

    • off
    • on / conservative
    • balanced
    • level <1-10>
    • level <1-10> preamble <16|32>
    • manual timing: <rx_us> <sleep_us>
  • Adds a read command:

    get radio.rxps

  • Persists RX power saving settings in NodePrefs:

    • enabled/disabled state,
    • RX window duration,
    • sleep duration,
    • selected level,
    • optional preamble override.
  • Automatically recalculates RX power saving timings after radio SF/BW changes when level-based mode is used.

  • Adds RX duty-cycle support to RadioLib wrappers for:

    • SX1262 (works beautifully),
    • LR1110 (probably does not work yet, I tried with my T1000-E and it has problems receiving packets)
  • Falls back to normal continuous RX if duty-cycle RX cannot be started.

  • Skips adaptive noise floor sampling while RX power saving is enabled, because the radio sleeps between receive windows and RSSI reads from a sleeping frontend can corrupt the noise floor estimate.

  • Integrates the feature into the example apps:

    • simple repeater,
    • simple room server,
    • simple sensor,
    • companion radio.

Behavior

When radio.rxps is enabled, the radio no longer stays in continuous receive mode. Instead, it alternates between a short RX window and a sleep period. This can reduce power consumption, with the tradeoff that timings must match the LoRa parameters and preamble length closely enough to catch incoming packets.

The level mode calculates rx_us and sleep_us from the current SF, BW, and preamble length, so users can select a simple power saving level instead of manually calculating timings.

Compatibility

RX power saving is disabled by default, so existing configurations should continue to behave as before. Radios that do not support this feature report an error when enabling it is requested.

More info

Testing is described here: https://discord.com/channels/1472633259799220224/1472634841077121144/1524118609134096405 (IoTThinks discord)

Does it save power?

Hell yes!

Xiao NRF52 + Wio SX1262

obraz obraz obraz

4V to battery connector (powersaving on), current average measured by PPK2:
set radio.rxps off - 6.92mA

set radio.rxps conservative - 6.2mA (in reality this is level 1 preamble 16)
set radio.rxps balanced - 4.66mA (in reality this is level 5 preamble 16)

set radio.rxps level 1 preamble 16 - 6.2mA
set radio.rxps level 2 preamble 16 - 5.76mA
set radio.rxps level 3 preamble 16 - 5.37mA
set radio.rxps level 4 preamble 16 - 5mA
set radio.rxps level 5 preamble 16 - 4.66mA
set radio.rxps level 6 preamble 16 - 4.35mA
set radio.rxps level 7 preamble 16 - 4.05mA
set radio.rxps level 8 preamble 16 - 3.79mA
set radio.rxps level 9 preamble 16 - 3.52mA
set radio.rxps level 10 preamble 16 - 3.3mA

set radio.rxps level 1 preamble 32 - 4mA
set radio.rxps level 2 preamble 32 - 3.77mA
set radio.rxps level 3 preamble 32 - 3.55mA
set radio.rxps level 4 preamble 32 - 3.33mA
set radio.rxps level 5 preamble 32 - 3.12mA
set radio.rxps level 6 preamble 32 - 2.92mA
set radio.rxps level 7 preamble 32 - 2.72mA
set radio.rxps level 8 preamble 32 - 2.54mA
set radio.rxps level 9 preamble 32 - 2.36mA
set radio.rxps level 10 preamble 32- 2.19mA

... and with RX gain off we have 2.1mA

obraz

Heltec T096

4V to battery connector (powersaving on), current average measured by PPK2:
set radio.rxps off - 8.6mA

set radio.rxps conservative - 8.07mA (in reality this is level 1 preamble 16)
set radio.rxps balanced - 6.55mA (in reality this is level 5 preamble 16)

set radio.rxps level 1 preamble 16 - 8.07mA (6% lower power consumption)
set radio.rxps level 2 preamble 16 - 7.6mA
set radio.rxps level 3 preamble 16 - 7.2mA
set radio.rxps level 4 preamble 16 - 6.85mA
set radio.rxps level 5 preamble 16 - 6.56mA (24%)
set radio.rxps level 6 preamble 16 - 6.2mA
set radio.rxps level 7 preamble 16 - 5.92mA
set radio.rxps level 8 preamble 16 - 5.66mA
set radio.rxps level 9 preamble 16 - 5.42mA (32%)
set radio.rxps level 10 preamble 16 - 5.2mA (39.5% lower power consumption)

set radio.rxps level 1 preamble 32 - 5.85mA (31%)
set radio.rxps level 2 preamble 32 - 5.62mA
set radio.rxps level 3 preamble 32 - 5.4mA
set radio.rxps level 4 preamble 32 - 5.2mA
set radio.rxps level 5 preamble 32 - 5mA (42%)
set radio.rxps level 6 preamble 32 - 4.8mA
set radio.rxps level 7 preamble 32 - 4.6mA
set radio.rxps level 8 preamble 32 - 4.43mA
set radio.rxps level 9 preamble 32 - 4.25mA (50%)
set radio.rxps level 10 preamble 32- 4.1mA (52%)

The uglies

All clients with firmware older than 1.16.0 (where preamble is set to 16) will not be heard by your repeater if you set rxps preamble to 32 !!!
In that case you need to set rx powersaving with preamble 16, this does not save as much power, but the repeater will be able to detect messages with old short preamble.

@Dom4n

Dom4n commented Jul 8, 2026

Copy link
Copy Markdown
Author

Problem

After extended operation with radio.rxps enabled, an SX1262-based repeater (Heltec T096, nRF52) stopped responding on the radio while the MCU kept running. The radio wrapper in my opinion was stuck in STATE_RX with the chip silently sitting in STDBY — no DIO1 IRQ ever arrives in that state, so the firmware never re-arms RX.

Possible root cause: SX126x RTC not stopped after duty-cycle reception

In SetRxDutyCycle mode the RX/SLEEP windows are driven by the chip's internal RTC. Exiting the duty cycle (RxDone, or standby before reconfig/TX) does not reliably stop that RTC; its pending timeout event can later terminate a subsequently started RX, dropping the chip to STDBY without raising any IRQ.

Semtech treats stopping the RTC as mandatory, not as an implicit-header-only fix:

  • Semtech sx126x_driver (sx126x.h): sx126x_stop_rtc() — "Workaround — It is advised to call this function after ANY reception with timeout active sequence, which stop the RTC and clear the timeout event, if any (see DS_SX1261-2_V1.2 datasheet chapter 15.4)"; sx126x_handle_rx_done() — "must be called after any reception with timeout active sequence".
  • LoRaMac-node (src/radio/sx126x/radio.c, RadioIrqProcess): applies the same register writes (REG_RTC_CTRL 0x0902 = 0x00, REG_EVT_CLR 0x0944 |= 0x02) on every non-continuous RxDone, despite LoRaWAN using explicit headers.
  • SX1261/2 datasheet (DS_SX1261-2, ch. 15.3/15.4 "Known Limitations") documents the register-level workaround; ch. 13.1.7 (SetRxDutyCycle) documents that the duty cycle sequence may only be exited via RxDone or an explicit SetStandby.

RadioLib only implements this workaround in fixImplicitTimeout(), which is gated to implicit LoRa header mode and never reached from MeshCore's explicit-header path.

Fix: CustomSX1262::stopRTC() (port of sx126x_stop_rtc()), called via stopReceiveDutyCycle() (standby + RTC stop) whenever a duty-cycle receive is torn down — before every RX re-arm and before TX. Guarded by a new _rx_ps_armed flag, so the stock continuous-RX path (RX_TIMEOUT_INF, RTC never runs) is untouched. The writes themselves are idempotent (LoRaMac-node issues them after every packet).

Related fixes

  • TX started while the duty-cycle sequencer is running. RadioLib's stageMode(TX) does not issue SetStandby, so SetTx was sent with the sequencer active — per datasheet ch. 13.1.7 the sequence must be stopped by SetStandby first; a pending RTC event could otherwise fire mid-transmission. startSendRaw() now calls stopReceiveDutyCycle() first when armed.
  • Ghost packets after RX timeout. In duty-cycle mode DIO1 also fires for RX timeout (false preamble detect) and header errors. GetRxBufferStatus then still reports the previous packet's length, so recvRaw() re-delivered stale buffer contents as a new packet. New isPacketReady() guard only reads the buffer when the radio reports an actual RxDone (checkIrq(RADIOLIB_IRQ_RX_DONE)); behavior with rxps off is unchanged.
  • Main-loop stalls in isReceiving() before TX. With the chip in a duty-cycle sleep window, BUSY is held high and any SPI status read blocks until the next listen window (RadioLib Module::SPItransferStream waits for BUSY before transfer). isReceiving() now returns false immediately when BUSY is high (a sleeping chip cannot be mid-receive), and the RSSI-based interference check is skipped in rxps mode (instantaneous RSSI of a part-time-sleeping frontend is meaningless).
  • LR1110: RX timeout / CRC / header-error IRQs are now routed to the IRQ pin (previously only RxDone), so the wrapper notices a failed duty-cycle receive and re-arms instead of waiting forever; same standby-before-reconfig handling via _rx_ps_armed. No RTC-stop equivalent is documented for LR11x0, so none is applied.

PS. Sorry for em-dashes 🤣 I have used AI to translate my notes from Polish to English.

@IoTThinks

Copy link
Copy Markdown
Owner

Let me test with Heltec T114.
Wow, we really need to touch RTC.
@Dom4n If we use auto mode, do we need to touch RTC?

@Dom4n

Dom4n commented Jul 8, 2026

Copy link
Copy Markdown
Author

@Dom4n If we use auto mode, do we need to touch RTC?

In my opinion - yes, because RadioLib handles this only for implicit header (like described in the semtech datasheet), but semtech drivers and LoraMac firmware is handling this like I am here.

@Dom4n Dom4n left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noise floor reported 0 with rxps enabled

The initial rxps commit disabled adaptive noise-floor sampling entirely in duty-cycle mode (an RSSI read during the sleep window reads a powered-down frontend and would corrupt the average and the SPI access stalls until the next listen window, since RadioLib waits on BUSY before every transfer). As a result _noise_floor never left its initial 0, and RSSI-based interference detection (int.thresh) was dead in rxps mode.

Fixed by gating per-sample instead of disabling: a new isChipBusy() helper (BUSY pin high = chip in the duty-cycle sleep window or processing a command) lets loop() sample RSSI only during listen windows, exactly when the receiver is actually listening, which is the RSSI that matters in this mode. The same gate re-enables the int.thresh interference check in isChannelActive(). Listen windows are long relative to the main loop (e.g. ~49 ms RX / ~8 ms sleep for conservative at SF8/BW62.5), so the 64-sample average converges as usual. Continuous-RX behavior is unchanged (isChipBusy() defaults to false; gate only active while duty-cycle is armed).

@Dom4n Dom4n left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit adds watchdog-based recovery for the RX power-saving duty cycle.

The issue observed in long-running tests was that the radio could become permanently deaf after a few hours of operation. Once this happened, the node stopped receiving packets and did not recover through normal receive rearming. The only reliable recovery was a full hard power reset of the device.

I investigated whether this was caused by CAD behavior or AGC state, including AGC reset attempts, but those paths did not restore receive operation. The failure mode appears to be the radio falling out of the expected RX duty-cycle behavior without generating an interrupt or otherwise giving the existing state machine a chance to recover.

This change adds an RX power-saving watchdog that monitors whether the radio still shows the expected duty-cycle activity. If the watchdog detects that RX duty cycling is stuck, it first performs a soft rearm by forcing the wrapper back to idle so receive mode can be started again. If the radio remains stuck, it escalates to a hard radio reinitialization through the SX1262 reset/init path, then reapplies cached radio parameters and TX power.

The commit also exposes watchdog recovery counters through get rxps.wd, reporting soft and hard recovery counts. This should make field verification easier and help confirm whether devices are recovering from the deaf-radio condition without requiring a full device power cycle.

@IoTThinks

Copy link
Copy Markdown
Owner

It may relate to the wrong noisefloor just now.
If the noisefloor is high, MC may delay or stop TX.

@Dom4n

Dom4n commented Jul 9, 2026

Copy link
Copy Markdown
Author

The noise floor calculation is working again, and was working when I tested it through the night today. With working noise floor calculation it still hanged my radio module (one of three, maybe my module has a flaw).

@Dom4n Dom4n left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this time I have nailed it. Noise floor calculation is working as it should or even better on esp32 variant than it was on original powersaving branch with no noticeable addition to the current needed to do it.

I will test for radio hangs in coming days, but it looks like the culprit has been found and fixed.

@towerviewcams

Copy link
Copy Markdown

As this gets figured out, tested and made to work well - It will be amazing. We have lots of summer to test this and make perfect,so, when cloudy months hit October/early November. we are ready. Looks like at the highest savings 52% will be huge when we have no sun for more then 2 weeks. happens all the time and longer in the PNW.

@IoTThinks

Copy link
Copy Markdown
Owner

Wow, great.
I also hope the noise floor calculation is the culpit.

As there is rssi check before sending, TX may be paused if RSSI from noisefloor is bad.

@IoTThinks

Copy link
Copy Markdown
Owner

@towerviewcams 5w panels were fine last winter in freezing temperature.

With this PR, 2w panels are likely sufficient too.

@towerviewcams

Copy link
Copy Markdown

@towerviewcams 5w panels were fine last winter in freezing temperature.

With this PR, 2w panels are likely sufficient too.

Most people in PNW have 2 watt panels. We also have more then 400% additional activity since then. I know what happen with just 4.5 days of no sun just over a week ago. This winter will be MUCH different

@towerviewcams

Copy link
Copy Markdown

Testing max RXPS now. Level 10 and preamble 32. Working so well in very noisy location in very busy PNW cascadia mesh system. very responsive

@Dom4n

Dom4n commented Jul 17, 2026

Copy link
Copy Markdown
Author

rebased to current PowerSaving-v16 branch

@IoTThinks

Copy link
Copy Markdown
Owner

Let me test and merge this weekends.

Then we start adjusting minor stuffs from there.

Things work beautifully.

@IoTThinks

IoTThinks commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Heltec T096
powersaving on
gps off
set radio.fem.rxgain off <===== For T096, we must turn of FEM as FEM uses 10mA.
set radio.rxps level 9 preamble 16 <==== 16 for maximum compability and 9 (not go to the maximum)
set agc.reset.interval 0 <=== disabled
reboot

It seems when I reboot, I can see noisefloor 0db in first 2 minutes.
Then after that the noisefloor is back to normal at -115.

Let me monitor the behavior so we can reply to MeshCore friends.
image

@IoTThinks

Copy link
Copy Markdown
Owner

After exactly 1 minute, the noise floor will be from 0 to normal -115.
It is great.

@Dom4n

Dom4n commented Jul 18, 2026

Copy link
Copy Markdown
Author
  1. Noise floor calibration is now working right after start / restart.
  2. LR1110 works now!
  3. last message snr and rssi should work (in testing phase again, but promising)

@IoTThinks

Copy link
Copy Markdown
Owner

it works great on my T096 immediately after reboot.
Noise floor is not 0dB any more.

I will test L1110 on my T1000e. Should be ok.

@Dom4n

Dom4n commented Jul 18, 2026

Copy link
Copy Markdown
Author

Now it is easy to set fixed rxps for companions via build flags in platformio.ini files.
If this default is not welcome then please say a word and I will turn it off.
Currently the default is level 5 with preamble 16.

@IoTThinks

Copy link
Copy Markdown
Owner

Level 5 preample 16 should be fine and safe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants