Skip to content

PowerSaving v18 - #26

Open
Dom4n wants to merge 44 commits into
IoTThinks:PowerSaving-v18from
Dom4n:PowerSaving-v18
Open

Dom4n wants to merge 44 commits into
IoTThinks:PowerSaving-v18from
Dom4n:PowerSaving-v18

Conversation

@Dom4n

@Dom4n Dom4n commented Oct 16, 2026

Copy link
Copy Markdown

I have updated the PowerSaving to work on main firmware v1.18.0

What is NOT included:

  • GPS PowerSaving: I have yet not audited the code for this, so I have no idea how it works, so I didin't bother to move it to v1.18
  • femon/femoff builds, because now femoff it the default and if someone wants to enable FEM then just do it via cli (companion too)
  • MKE_s3 - I have no idea what this is
  • script "build-iotthinks.sh"
  • some documentation probably is missing

What 1.18 already had:

  • ESP32 clock survives restart
  • lr1110 dc-dc
  • sensors in Wio L1 Pro
  • RAK3401 without WB_IO2
  • turn off led before shutdown
  • MCU temperature in sensor fw
  • USB in Xiao S3 Wio
  • FEM in Heltec V4.3 R8
  • ltoa in stm32 builds
  • remove NRF_power_management

Done differently because it wasn't straightforward:

  • companion RXPS got cli! default is balanced 6/16
  • shutdownPeripherals() for power off and timed sleep, because powerOff() in main was redone
  • nRF52 clock reset - more than what I though it should take... linker scripts, .persistent section etc
  • esp32 light sleep for companion now works for all companion types ! sick!
  • LR2021 fix (I'm currently testing this module and it had bugs in 1.18, I will make PR to main)
  • GPS was redone in main, so I implemented gps fix from e3d7feb a little bit differently
  • SSD1306 fixes from PS17

Different fixes that I was waiting to do a PR for PS17:

  • RXPS fix - if after recalculation the sleep/rx is not in safe area instead of writing it to flash just turn off rxps (works for example if someone is testing different spreading factors and goes from to one that the profile can't work because of physics)
  • RXPS can't be armed because reasons -> now it tries 3 times and gives up and goes to continous RX. Much more safe option that to leave the repeater in broken mode.
  • Sleep was always 30 seconds, now the sleep time is calculated and it sleeps until something is scheduled (like for example wait for rxdelay and wake up in time, not after 30 seconds). And the maximum wait time is 30 seconds. No sleep when usb is enabled. Additionally it fixes room server sleep (it needs to wake up every few seconds to send messages to the companion, earlier we had one message per 30 seconds). Sensor: wake up for configured sensor check and all alerts.

All different functions are done it different commits, so it is easy to track what was changed what for.

Dom4n added 30 commits October 15, 2026 10:49
gpsResetPin defaulted to 0, which is a real GPIO on nRF52 (P0.00, the
32 kHz LFXO crystal). -1 makes pinMode/digitalWrite no-ops until a GPS
enable pin is actually found.
getSettingByKey("gps") returns NULL on sensor managers without a gps
setting, and strcmp(NULL, "1") crashed.

From PowerSaving-v17 e3d7feb.
PIN_VBAT_READ=1 (D0) as in the PowerSaving-v17 build.

From PowerSaving-v17 43882d8.
The Bluefruit library blinks LED_BLUE while advertising. DISABLE_BLE_LED
keeps it off; the upstream LED init (HIGH = off) stays, so the
active-LOW pin is never left floating.

From PowerSaving-v17 e7bd0a8 (same goal, different mechanism).
Repeater and room server builds point ENV_PIN_SDA/SCL at the Wire1 bus.

From PowerSaving-v17 ba1a2ff.
ARDUINO_USB_MODE in heltec_v4.json has no effect, so the flag is set in
build_flags. The KISS modem env still unsets it.

From PowerSaving-v17 1a7f70e.
USE_CC310_HW_CRYPTO (upstream PR meshcore-dev#2824) stays off until its benefit is
proven: no measurable current difference on RAK4631 with PowerSaving,
and the per-call nRFCrypto begin()/end() was questioned in review.
Re-enable by uncommenting the flag.

From PowerSaving-v17 d302651.
heltec_v4_expansionkit_tft_companion_radio_ble is the TFT companion with
environment sensors on SDA 4 / SCL 3 of the expansion kit.

From PowerSaving-v17 0e47bdf and 320ce56.
- Heltec_t114_sensor
- t1000e_sensor
- heltec_tracker_v2_repeater_bridge_rs232
- RAK_3401_repeater_bridge_rs232_serial1
- Xiao_nrf52_repeater_bridge_rs232

From PowerSaving-v17 1ce07cd, 54a79f4, b7cc0e6 and 41c63df.
Two more lines on the radio page of the repeater display: battery
voltage and whether powersaving is on.

From PowerSaving-v17 4cb67d9.
A 64-bit Uptime counter is ticked from UITask::loop(), so it keeps
counting while the screen is off and across the 32-bit millis() wrap.
The page dots are left-aligned and their step shrinks only when they
would run into the uptime (9 pages on a 128 px display).

From PowerSaving-v17 54f292f, 167a32f and 4eae86f.
Black background with green/yellow text, as before v1.17. On the T096
the light theme costs about 3.5 mA more with the backlight on.

From PowerSaving-v17 e1758d2.
With the display supply cut, CS/DC/SDA/SCL/RST/LEDA held as outputs
back-power the panel (about 2.8 mA on the T096). turnOff() now releases
the SPI bus and switches the pins to inputs; turnOn() restores
CS/DC/RST/LEDA and starts the SPI bus again before re-init.

SCK/MOSI must not be restored with pinMode(): on ESP32 it routes the
pin back to plain GPIO, detaching it from the SPI peripheral, and on
nRF52 it drops the SCK input buffer that SPIM needs. The panel then
gets no init commands and wakes with the backlight on and a blank
screen.

From PowerSaving-v17 6e4e6f1.
begin() claims the peripheral power only when the display was off and
releases it again if no panel answers, so claims and releases stay
balanced. turnOn() re-runs begin() only when the supply was switched
off. Without switched power the panel kept its state, and a full
begin() would pulse PIN_OLED_RESET, which defaults to 21 =
SX126X_POWER_EN (FEM enable) on RAK3401 and cut the FEM for ~10 ms on
every display wake.

Host tests in test_ssd1306 use display mocks; run them with
"pio test -e native_ssd1306".
- get sensor: the I2C pins in use for environment sensors (Wire1 or
  Wire) and the GPS serial pins, with whether GPS is configured.
- get powerlog: last reset reason and, where power management is
  initialised, the shutdown reason and boot voltage.

From PowerSaving-v17 4cb67d9.
The BSEC library's extra_script selects the cortex-m4 soft-float blob,
but nRF52840 builds with -mfloat-abi=hard, so linking failed.
fix_bsec_lib.py prepends the fpv4-sp-d16-hard library path.

From PowerSaving-v17 5965010, 9a1be4b and 9cd7a84.
During light sleep PIN_USER_BTN is also a GPIO wake source. When the
button is still pressed after wake-up, sleep is held off for 30 s so the
button and display can be used; the hold-off uses a uint32_t millis()
difference and survives the wrap.

The TLoRa V2.1 repeater has no usable user button (GPIO0 wake kept it
from booting), so it sets PIN_USER_BTN=-1 after the base flags, where
the last -D wins.

From PowerSaving-v17 da1cc88 and 374aba4.
Generic_ESPNOW_* envs did not build: ESP32Board used P_LORA_DIO_1 and
P_LORA_NSS unconditionally. Without them getIRQGpio() falls back to
"none", sleep() stays awake (there is no LoRa wake source and Wi-Fi must
keep receiving) and the NSS hold in shutdownPeripherals() is skipped.
ESPNOWRadio::setRxBoostedGainMode() now returns a value.
'set reboot.interval <0-255>' (hours since boot, 0 disables) for the
repeater, room server and sensor. The value takes a former padding byte
of the prefs file. A due reboot waits until the current transmission
and any active OTA session have finished.

From PowerSaving-v17 0024226 and 4cb67d9.
Repeater, room server and sensor start with powersaving_enabled = 1.
A saved setting still wins.

From PowerSaving-v17 9414de8.
A companion must not go idle while received frames wait to be
processed. BaseSerialInterface defaults to false, so Wi-Fi, RP2040 BLE,
Ethernet and Arduino serial need no change. ESP32 and nRF52 BLE report
their receive queues, and MultiSerialInterface aggregates the enabled
interfaces.

From PowerSaving-v17 594d770.
With rxdelay, received packets are queued for later processing. The
repeater's hasPendingWork() only looked at the outbound queue, so the
MCU could sleep past their scheduled time. PacketManager now reports
the inbound queue length (StaticPoolPacketManager: rx_queue) and the
repeater counts it as pending work.
Repeater, room server, sensor, companion BLE/USB/Wi-Fi, ESP-NOW bridge
and terminal chat envs; BOOT works as the user button.

No RS232 bridge env: in the v17 wiring GPIO5 is both the SX1262 reset
and the bridge RX. PinChecks.h rejects any build where the bridge RX or
TX pin equals P_LORA_RESET.

From PowerSaving-v17 f024668, a4fb9d2, 9639763, 3d830a9 and
fc182b7.
From the remote admin console a client can read or set the path the
repeater uses for replies to it: flood, direct, clear (automatic
learning) or an explicit list of 1-, 2- or 3-byte hop hashes. Hop lists
are parsed with full hex validation.

A forced flood is stored in the ACL and survives reboot, login and
learned PATH messages. Direct and hop lists are replaced by the next
flood login or PATH, as before. The ACL is saved about 5 s later.

From PowerSaving-v17 0024226 and 4cb67d9.
nRF52840 + SX1262 with TCA9535 expander, ADS1115, NV3031B LCD with
GT911 touch, GNSS and audio. Repeater, room server and companion
BLE/USB envs. getTouch() delegates to the variant's readTouch(), which
checks for an actual touch before reading coordinates.

From PowerSaving-v17 6c7b751 (upstream PR meshcore-dev#3381).
Replace the git commit pin with the 7.8.1 release. CustomLR2021 now
updates its cached boosted-gain state only when setRxBoostedGainMode()
succeeds, so getRxBoostedGainMode() reads back the level the radio
accepted.

From PowerSaving-v17 c0c0354.
VolatileRTCClock stores the current time and a magic number in an
8-byte NOLOAD .persistent section and restores it after a reset. A bad
magic or a time before 2026-03-01 falls back to that date. prev_millis
is uint32_t so the millis() wrap does not jump the clock.

The v6, v7 and extrafs linker scripts gain the .persistent section, and
ten nRF52 board bases select the repo's script explicitly so the
section is present on every build. Retention covers software resets
only, not power loss.

From PowerSaving-v17 2384af1 and 772dc84.
MainBoard gains shutdownPeripherals() and enterDeepSleep(secs). Board
hooks that used to override powerOff() now override
shutdownPeripherals(), so a timed sleep turns off the same GPS, display,
LEDs, FEM/PA and rails as a full power off.

- ESP32: enterDeepSleep() runs shutdownPeripherals() and arms the timer
  wake-up.
- nRF52: SYSTEMOFF has no timer wake, so a timed sleep keeps the RTOS
  running in one-second steps, advances the retained clock, then
  reboots.
- Heltec V4 R8: its own deep-sleep path left the RX path on; it now uses
  the shared one, which turns off the radio and FEM.
- Wio Tracker L1 1W: the PA is now also turned off on timed sleep.

From PowerSaving-v17 d9c4296.
Dom4n added 13 commits October 16, 2026 00:17
RX power saving (RXPS) keeps the radio asleep between short RX windows
that are still long enough to catch a preamble.

- Levels 1-10 with preamble 16 or 32 symbols. Levels 9
  ("overdrive") and 10 ("riskyWorkingMax") run outside the datasheet
  timing rule.
- Presets: on/conservative = 3, balanced = 6, max = 8.
- Manual rx/sleep periods of 1000-30000000 us.
- The calculator models the chip's preamble capture cost and rejects
  invalid SF/BW, NaN/Inf and overflowing periods.
- A profile that does not fit the active modulation falls back to
  continuous RX, flags a timing error and keeps the saved intent, so
  stale periods are never armed.
- RXPowerSavingCLI parses 'set radio.rxps ...' and formats
  'get radio.rxps'. The 'catch' margin is shown only when the
  windows are valid and no error is pending.

Pure logic with host tests in test_rx_power_saving; radios and roles
use it in the following commits.

From PowerSaving-v17 285cf10 and 6bbd34a.
RadioLibWrapper gains the RXPS state machine. When enabled, RX is
started through the driver's armDutyCycle() instead of continuous
startReceive(). Requested and effective periods are kept apart. TX,
CAD, idle, power off and any radio reconfiguration (via
prepareForRadioConfig()) stop the duty cycle first; recvRaw() reads a
packet only once RX_DONE is set.

A failed arm stops the hardware duty cycle and falls back to continuous
RX, recording the error.

CustomSX1262 implements the duty cycle:
- TCXO start-up delay of 1600 us, as in v17;
- BUSY is checked so the sleeping radio is not hit by SPI;
- RXEN returns to RX;
- the duty cycle is stopped through standby.

LR2021 keeps its idle() before setOutputPower() in setTxPower(), which
the PA/TxParams standby-only commands need (upstream 114093e).

Host tests in test/host (CMake, needs a RadioLib source tree):
wrapper_lifecycle covers TX, CAD, arm failure and on/off, and
wrapper_lifecycle_lr2021 covers the LR2021 set-tx path.

From PowerSaving-v17 285cf10.
RadioLibWrapper counts CAD scans that detect LoRa activity (not free
channel results or scan errors) and resets the count with the other
packet stats. 'get cad' prints "on/off; channel busy: N". ESP-NOW has no
CAD and reports 0. In v17 the counter was declared but never
incremented.
- Calibration: the periodic noise-floor/AGC calibration runs in
  continuous RX, then the duty cycle is re-armed. A frame that starts
  during calibration is not aborted, and RSSI/SNR of the last packet
  are cached so they can be read while the radio sleeps.
- Mode changes: a change of RX mode waits for a frame in flight or a TX
  to finish. RX restart requests keep an interrupt that the ISR raises
  meanwhile.
- Retries: after three failed arm attempts the radio stays in
  continuous RX until the RXPS setting changes, instead of paying for a
  failing SPI round-trip on every RX restart.
- Errors: continuous RX is started through a virtual
  startContinuousReceive(), whose result is reported as the RXPS
  'err' status (RXPS off, during calibration, after the retry limit and
  as the arm-failure fallback).

From PowerSaving-v17 0965a6a and be4b276.
CustomLR1110 arms the duty cycle with its own period conversion. The
stock RadioLib 7.8.1 path multiplies the period by 32768 in 32 bits and
overflows above ~131 ms. Arming switches the LF clock to RC with BUSY
release.

Stopping the duty cycle restores the XOSC LF clock with BUSY release
off. Every continuous RX first enters standby and restores the clock,
so a failed restore cannot leave continuous RX on the RC clock.

driver_spi in test/host checks the SPI commands that the SX1262 and
LR1110 drivers send for a range of periods, including the 6553-tick
LR1110 command for 200 ms. It also checks injected command errors, the
stop paths, and that every calculator profile yields valid periods
above the radio's transition time.

From PowerSaving-v17 307bc93.
Both NodePrefs types (repeater/room/sensor and companion) store the
RXPS config under the v17 JSON keys: rxps_en, rxps_rx_us,
rxps_sleep_us, rxps_level and rxps_preamble. Fresh installs start with
Balanced (level 6, preamble 16); a saved setting, including off, wins.

CommonRadioPrefs handles 'get radio.rxps' and 'set radio.rxps' for
every role and applies the config to the RadioLib wrapper. Temporary
radio parameters do not overwrite the stored automatic periods.

'set radio' re-checks the RXPS timing for the new SF/BW. If the profile
no longer fits, the node drops to continuous RX with err=-10001 while
the saved choice is kept.

config_lifecycle in test/host (GoogleTest) loads and saves the real
NodePrefs of both types through the shared CLI.

From PowerSaving-v17 aa15933 and b1119e3.
Repeater, room server, sensor and companion apply the stored RXPS
config after setParams() at boot, after temporary radio params and
their revert, and after the companion's CMD_SET_RADIO_PARAMS.

The repeater and companion do not count as idle while transmitting or
while the noise-floor calibration holds the radio in continuous RX.

From PowerSaving-v17 aa15933, 78e134b and fd14208.
…r all roles

Fresh installs of every role start with the external LoRa FEM receive
LNA off, which saves power; a saved setting wins. A build can default it
on with -D RADIO_FEM_RXGAIN=1, replacing the separate _femon/_femoff
images of v17.

'get/set radio.fem.rxgain' is handled once in CommonRadioPrefs:
- it accepts only on or off;
- it answers "Error: unsupported" on boards without a controllable LNA;
- it marks the prefs dirty so the value is saved even when it lives in
  the dynamic store.

config_lifecycle covers the defaults, persistence across save/load and
unsupported boards.

From PowerSaving-v17 9414de8, 0e47bdf, fde20d3, bbcec95 and
d6a75ff.
Same package as PowerSaving-v17 (esp32-2.0.17.zip). It enables the
power management and tickless-idle configuration that automatic light
sleep needs; the stock 2.0.17 core has it disabled.

esp32c6_base now extends arduino_base, so C6 boards stay on the
pioarduino 3.x framework (without power management).

From PowerSaving-v17 c0af1b5 and cb7c4b0.
The companion stores the flag as power.pwr_sav_en, the same key as
CommonCLI. It is on for fresh installs; a saved value wins. The
'powersaving', 'powersaving on' and 'powersaving off' commands read and
save it. On its own the flag does nothing yet; the ESP32 light sleep
that uses it follows.

From PowerSaving-v17 594d770 and 9414de8.
esp_pm scales the CPU between 80 and 40 MHz. When powersaving is on,
it also enables automatic light sleep, and the BLE controller sleeps
between connection events. The idle loop yields to the idle task only
when no work is pending and no interface has a frame queued
(isReadBusy/isWriteBusy). Changing the flag from the CLI reconfigures
power management without a reboot.

Light sleep stays off, keeping frequency scaling only, where a wake-up
source would be missed:
- USB CDC;
- hardware UART (SERIAL_RX);
- Ethernet (INT pin);
- ESP-NOW (the Wi-Fi receiver is the mesh radio);
- a configured Wi-Fi network.

POWERSAVING_DEBUG_PRINT/LN log these decisions when built with
-D POWERSAVING_DEBUG=1.

From PowerSaving-v17 594d770, d42f424, a3b9ad9, 9c728e6, 4027acc
and 1e888cb.
With powersaving on, the room server sleeps when it has no pending work.
- Pending work: an outbound or delayed inbound packet, a TX, the
  noise-floor calibration, a CLI command or serial input.
- Sleep length: the time to the next deadline (adverts, temporary
  radio params and their revert, contact save, push to logged-in
  clients), at most IDLE_SLEEP_MAX_MILLIS = 30 s, the repeater's and
  v17's limit. A LoRa packet or the user button wakes it earlier.

Shared helpers:
- IdleSleepBudget computes the sleep length; tested in test_idle_sleep.
- sleepRoleIfIdle() (RoleSleep.h) keeps the node awake during OTA,
  with Ethernet, with active USB CDC, with GPS on, for the first 120 s
  after boot, and while a frame is being received. It observes the
  wrapper without starting CAD, so duty-cycled RX is not disturbed.
- MainBoard gains sleepMillis(); ESP32Board implements it as a light
  sleep with a microsecond timer.

From PowerSaving-v17 be6f359 and 61048f5.
With powersaving on, the sensor sleeps like the room server, through
sleepRoleIfIdle(). The sleep is shortened to the next sensor read and
to the send deadline of every queued alert. It does not sleep when a
read is already due. Delayed inbound packets, TX and the noise-floor
calibration count as pending work.

From PowerSaving-v17 e125d37 and 1ce07cd.
@IoTThinks

Copy link
Copy Markdown
Owner

I am doing my merge and will reference to this PR for comparision too.
Thanks a lot for the effort.

On Heltec Tracker V2 and Wireless Tracker the screen and the LoRa
radio share one SPI bus. Turning the screen off also stopped that
bus, so the radio hung on its next access and the whole device froze
(USB, screen and button) until reset.

Turning the screen off now releases only the screen's own lines and
keeps the bus running for the radio.
void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; }

CommonRadioPrefs* getRadioPrefs() { return &radio; }
const CommonRadioPrefs* getRadioPrefs() const { return &radio; }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

So we have two methods?
CommonRadioPrefs* getRadioPrefs() { return &radio; }
const CommonRadioPrefs* getRadioPrefs() const { return &radio;

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 is not a duplicate, this is normal C++ construct. Removing the const version would still work, but will need more changes in main meshcore.
It is here because it is needed by _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(), inside only const version is used so only const accessor can be used here. The non-const stays for paths that change radio settings - like applyRxPowerSaving() or handleCommand(). Same pair exists in CommonCLI.h for the other roles.

#if defined(NRF52_PLATFORM)
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
#elif defined(ESP32_PLATFORM)
if (the_mesh.getNodePrefs()->powersaving_enabled &&

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We have to test this.
Last time I use the same code, it is much slower for BLE companions as it may need to check all the interfaces.

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.

ok, this may be true, we need to test it as You said 👍

}

#if defined(ESP32_PLATFORM)
static uint8_t applied_powersaving = the_mesh.getNodePrefs()->powersaving_enabled;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Should this setting is already applied in CLI "powersaving on" of the Companion CLI?

It should be same to repeater CLI.

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.

Repeater sleeps a bit differently, it uses board.sleep(), but companion uses light_sleep and it is checked in main.cpp. This is companion specific, and the flag needs to be checked in case a user changes the powersaving flag on/off. applyCompanionPowerSaving is in main.cpp, and it depends on wifi/usb/uart state. Polling the setting here picks up the change without a reboot.

@IoTThinks IoTThinks left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

rxps.enabled = 0;

Comment thread src/helpers/CommonCLI.h

public:
NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) {
rxps.enabled = 1; // fresh installs: Balanced L6/p16; loaded fields override this

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is a note to myself.
I set this rxps.enabled = 0;
The defaults are in NodePres.h or MyMesh.h.

@IoTThinks

Copy link
Copy Markdown
Owner

I have merged all your RXPS code and most of your changes.
This PR really helps.

Some of the features are good.
I would love to merge them in later phase for better testing.

Comment thread src/helpers/radiolib/CustomLR1110Wrapper.h
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.

2 participants