Skip to content

Conversation

@fschrempf
Copy link
Contributor

@fschrempf fschrempf commented Dec 17, 2025

In order to reduce the power consumption of NRF52 repeaters, this implements CPU idling by suspending the main task that runs the Arduino loop().

During the idle interval the CLI and the UI (if available) will be unresponsive. The RF module will be kept in RX mode and upon receiving a packet, the interrupt will cause the processing loop to continue immediately before going back to idle after all outgoing packets have been transferred.

On a RAK4631 repeater this can reduce the power consumption during RX mode from around 12 mA to around 7.5 mA.

Feedback, tests, reviews and questions welcome!

@IoTThinks
Copy link

I intended to do the same before.
But it is quite hard to power down NRF52 and be waken up by RX events. The power consumption will be around 6mA.
NRF52 does not handle Rising High well.
Your PR looks complex. May be due to this issue.

So I ended up just do some power saving trick to keep the power down to 8.5mA with few code changes only.
The MCU is still running.

Hope we can push the power down more for NRF52

@fschrempf
Copy link
Contributor Author

fschrempf commented Dec 17, 2025

But it is quite hard to power down NRF52 and be waken up by RX events.

Yes, there is no real sleep mode for NRF52 so either put the CPU in idle to save power or shut it down completely. The latter requires full reinit at wakeup.

The power consumption will be around 6mA.

In which case? With the MCU shut down and only the RF module running in RX mode? That would be in that ballpark, yes.

NRF52 does not handle Rising High well.

Sorry, I don't get what you mean here.

Your PR looks complex. May be due to this issue.

Actually it doesn't look complex to me at all. It's pretty straight forward. Instead of letting the CPU run continuously, it stops it and resumes it after the idle interval is over or a packet is received. What exactly looks complex to you?

So I ended up just do some power saving trick to keep the power down to 8.5mA with few code changes only.

What "trick" would that be? Do you mean waitForEvent() here? It doesn't work for me. I'm still seeing around 14 mA @ 3.3V with your PowerSaving07 branch.

And if it would work, it would do the same thing: put the CPU in idle, right? My approach does it in a platform-agnostic way, which is better IMHO.

@ngavars
Copy link
Contributor

ngavars commented Dec 17, 2025

This feature has been long overdue. I tested it a bit with Heltec t114 (no screen) repeater. With stock FW I am seeing around 12 mA idle current on my cheap usb power meter. It is actually quite good - it used to be around 17 mA with stock FW not so long ago.

Then I flashed FW from this branch and immediately went for "set idle.interval 10000". Now my cheapo usb meter shows current at 0 mA, which occasionally jumps briefly to 10 mA while idling. If I send a message from my companion then the repeater wakes, repeats and then goes back to idling. Now, the 0mA is probably just an artifact of my usb meter. I will try some additional tests tomorrow, but what I see so far - the power consumption at idle is noticeably lower and repeater still seems to be repeating messages and responding to admin commands etc

What is the intended reasonable idle interval? Something like 1 to 3 seconds?

@IoTThinks
Copy link

May be I will add the CLI for esp32 based boards.

Should we share the same CLI to enable/disable power saving to both esp32 and nrf52?

Like set powersave 1?

In your PR, you set the idle period? How about wake up period?

@IoTThinks
Copy link

@ngavars You have to measure the current at the battery cable by power meter.

A usbc, it needs to turn on unneccessary components like uart chip, led...

@fschrempf
Copy link
Contributor Author

May be I will add the CLI for esp32 based boards.
Should we share the same CLI to enable/disable power saving to both esp32 and nrf52?

In my opinion we should use the same approach for ESP32 and NRF52 altogether. You should be able to take my generic implementation and simply add the board.sleep() implementation for ESP32 to be called before the loop is halted.

Like set powersave 1?

What would that be good for? A single parameter that sets the length of the sleep/idle interval is enough. If set to zero (default) there is no change compared to the current implementation in the main branch. Repeater admins can then decide if they want to save power by increasing the interval.

In your PR, you set the idle period? How about wake up period?

The idle interval is the time the CPU is idling/sleeping. The wake interval is hardcoded to five seconds or three minutes after reboot or after CLI activity. I don't think this needs to be a parameter for the user to change.

@fschrempf
Copy link
Contributor Author

What is the intended reasonable idle interval? Something like 1 to 3 seconds?

The idle.interval setting is in seconds, so 10000 will give you around 2.8 hours. I'm currently running my test repeater with 1800 (30 minutes), but I'm not yet sure what would be a good value. If you have the value set very high, the automatic adverts of the repeater might be delayed accordingly (until the next wakeup happens). Apart from that I currently don't see any other downsides.

@fschrempf
Copy link
Contributor Author

@ngavars Thanks for testing by the way!

Copy link

@mtlynch mtlynch left a comment

Choose a reason for hiding this comment

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

I'm just a user sharing my thoughts and have no authority in this project.

@fschrempf
Copy link
Contributor Author

fschrempf commented Dec 19, 2025

I'm just a user sharing my thoughts and have no authority in this project.

Thanks for your review anyway! Very much appreciated!

@fschrempf fschrempf marked this pull request as ready for review December 19, 2025 09:31
@fschrempf
Copy link
Contributor Author

I've had this running on my test repeater (RAK4630) for 3 days now and I can't see any negative or unexpected results. I currently do not have any ESP32 hardware to test, so if someone would be willing to help out that would be very much appreciated! 😃

@SaschaKt
Copy link

I've had this running on my test repeater (RAK4630) for 3 days now and I can't see any negative or unexpected results. I currently do not have any ESP32 hardware to test, so if someone would be willing to help out that would be very much appreciated! 😃

Can you attach/upload somewhere an bin file for v3 for testing?

@fschrempf
Copy link
Contributor Author

fschrempf commented Dec 22, 2025

@SaschaKt You can find an archive with all repeater firmwares built by the GitHub CI here: https://github.com/fschrempf/MeshCore/actions/runs/20347701758/artifacts/4916433935

@SaschaKt
Copy link

SaschaKt commented Dec 22, 2025

@SaschaKt You can find an archive with all repeater firmwares built by the GitHub CI here: https://github.com/fschrempf/MeshCore/actions/runs/20347701758/artifacts/4916433935

I flashed it. Sorry for feedback after a few seconds. for v3 the powersaving is not so efficient like Iotthinks solution. I'm measuring over Usb-C and the consumption goes down from 48mA to 39mA with your solution, with Iotthinks solution it goes down to 13mA measured over Usb-C. Measuring on battery input pins will be a few mA less. I don't think that the combination of both solutions will give more power saving because I think that Lightsleep at esp's with iotthink solution also reduce CPU activity

@fschrempf
Copy link
Contributor Author

@SaschaKt Thanks for testing! This is the expected behavior. This PR does not (yet) include the code for ESP32 sleep. And yes, the combination of both solutions won't give more power savings for ESP32 than #1107.

The reason for my PR is that it provides a generic approach that is also applicable for other platforms than ESP32 while still providing the possibility to be extended by platform-specific sleep.

If your test shows a slightly reduced power consumption and the repeater still works fine that's a success.

@SaschaKt
Copy link

@SaschaKt Thanks for testing! This is the expected behavior. This PR does not (yet) include the code for ESP32 sleep. And yes, the combination of both solutions won't give more power savings for ESP32 than #1107.

The reason for my PR is that it provides a generic approach that is also applicable for other platforms than ESP32 while still providing the possibility to be extended by platform-specific sleep.

If your test shows a slightly reduced power consumption and the repeater still works fine that's a success.

It works, I could go normal to cli and start OTA and flash with Iotthink variant again. Now again 13mA. 39mA is still too much. nrF have per default enabled power saving internally and are at the small consumption level, with tweaking maybe a few mA savings. But esp's needs an enabled lightsleep to be useful as a repeater. The advantage of an nrF is that still with enabled BT the power consumption is not noticeable higher then without. Where is the problem to have two power routines, one for esp's and one for NRFs? Also Iotthink made powersavings for nrF too which will reduce from 12.5 to 8.5mA, that's about 32%

@fschrempf
Copy link
Contributor Author

Where is the problem to have two power routines, one for esp's and one for NRFs

@SaschaKt I think we are talking past each other. There is no problem here. What I want to achieve is a two step solution:

  1. A platform-independent solution (with some power savings) with idling the CPU (without any sleep) that works in all cases and also for any future boards and platforms.
  2. Additionally platform-specific sleep functions (with more power savings) if available.

Apart from the differences mentioned in #1107 (review) this is purely a strategic difference from what #1107 does.

@SaschaKt
Copy link

Where is the problem to have two power routines, one for esp's and one for NRFs

@SaschaKt I think we are talking past each other. There is no problem here. What I want to achieve is a two step solution:

  1. A platform-independent solution (with some power savings) with idling the CPU (without any sleep) that works in all cases and also for any future boards and platforms.
  2. Additionally platform-specific sleep functions (with more power savings) if available.

Apart from the differences mentioned in #1107 (review) this is purely a strategic difference from what #1107 does.

You're right. I can confirm that with v3 the powerconsumption goes from 48mA to 39mA. That's an improvement..I hope that it would not interfere with hardware specific power saving ontop like iotthink has made..this has to be tested

@ngavars
Copy link
Contributor

ngavars commented Dec 23, 2025

I did some additional testing with power profiler and Heltec V3 board on stock firmware and on your firmware. Both measurements were captured after the initial boot/advert sequence, when the board starts idling. Heltec was powered through its battery connector with voltage set at 3307 mV.

With stock firmware the avg current is 45 mA.
With power option firmware the avg current is also around 45 mA until approx. 3 minute mark, when it drops down to around 35 mA.

With stock firmware (latest from web flasher):
image

With power option firmware, idle.interval=1800:
image

Hope this helps. I can also test it with Promicro board if you want.

Copy link

@4np 4np left a comment

Choose a reason for hiding this comment

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

Thanks for working on this @fschrempf 👍 It would be good to see some more power saving for my Solar RAK19003 node :)

Having said that, I wonder if if would be better if the idle interval would be computed automatically rather than using a fixed duration. For example, in The Netherlands the mesh has grown exponentially. Where 2 months ago the mesh saw some activity, now I see hundreds of daily messages (let alone adverts) and (some parts of) Belgium and Germany have been connected. I wouldn't be surprised to see more of Germany and France starting to appear. An idle interval that worked a couple of weeks ago (maybe even days ago) may not work as well today.

Additionally, if CPU idling is triggered, how would one be able to enter remote management mode? IMHO when trying to use remote management or fetching telemetry, idling should be cancelled and the idle timer should be reset.

_prefs.advert_loc_policy = ADVERT_LOC_PREFS;

_prefs.adc_multiplier = 0.0f; // 0.0f means use default board multiplier
_prefs.idle_interval = 0;
Copy link

@4np 4np Dec 30, 2025

Choose a reason for hiding this comment

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

Perhaps add a comment to better explain what this means:

Suggested change
_prefs.idle_interval = 0;
// CPU Idle Interval (in seconds).
//
// If enabled (> 0), the CPU will start idling if there has not been any radio activity for
// the specified number of seconds.
//
// Note: When the CPU is idling, remote management and / or UI will cease to function!
_prefs.idle_interval = 0;

Or shorter:

Suggested change
_prefs.idle_interval = 0;
_prefs.idle_interval = 0; // CPU will idle when no RF activity during this interval (in sec, 0 = disabled)

@ngavars
Copy link
Contributor

ngavars commented Dec 30, 2025

Additionally, if CPU idling is triggered, how would one be able to enter remote management mode? IMHO when trying to use remote management or fetching telemetry, idling should be cancelled and the idle timer should be reset.

It will wake up on any RX event. Remote management works nicely, at least in all my tests so far.

@ngavars
Copy link
Contributor

ngavars commented Dec 30, 2025

I did some more testing with Xiao NRF52840. Here's a comparison of stock firmware (dev branch) and power option firmware (idle.interval=1800). In both cases the board is idling for 1 minute and there are no radio events.

Stock FW
image

idle.interval=1800
image

@fschrempf
Copy link
Contributor Author

An idle interval that worked a couple of weeks ago (maybe even days ago) may not work as well today.

@4np How is the idle interval related to the mesh activity? During idle the repeater will still respond to incoming packets. Only outgoing packets will be left waiting until the timeout expires.

@fschrempf
Copy link
Contributor Author

I did some more testing with Xiao NRF52840.

Thanks for the additional tests. This corresponds pretty nicely to the results of my own tests on RAK4631.

@4np
Copy link

4np commented Dec 31, 2025

@4np How is the idle interval related to the mesh activity? During idle the repeater will still respond to incoming packets. Only outgoing packets will be left waiting until the timeout expires.

@fschrempf , what I meant is an idle interval that works today, may not work tomorrow. If I read the PR, and correct me if I'm wrong, in your case you set the idle interval to 30 mins. So if there's no rx during a 30 min window, the CPU will start idling.

To illustrate the issue, this summer I didn't hear anything at all on MeshCore and there were almost no repeaters or companions. In September / October, we had a small regional mesh with several messages per hour. Today, we have most of The Netherlands covered with the one of the most dense meshes in Europe and I receive several packets per second.

So an idle timeout of, in your case, 30 mins that in my case would have been valid in September or summer would never trigger today.

As such IMHO it would be better to have an algorithm compute the best idle interval and have it adjust automatically on changing conditions. New repeaters pop up every single day.

ps. I think ill configured repeaters doing flood adverts at short intervals are the primary cause of the many packets per second, and putting a strain on the mesh. Reducing the number of configuration options could be beneficial as well.

@SaschaKt
Copy link

@4np How is the idle interval related to the mesh activity? During idle the repeater will still respond to incoming packets. Only outgoing packets will be left waiting until the timeout expires.

@fschrempf , what I meant is an idle interval that works today, may not work tomorrow. If I read the PR, and correct me if I'm wrong, in your case you set the idle interval to 30 mins. So if there's no rx during a 30 min window, the CPU will start idling.

To illustrate the issue, this summer I didn't hear anything at all on MeshCore and there were almost no repeaters or companions. In September / October, we had a small regional mesh with several messages per hour. Today, we have most of The Netherlands covered with the one of the most dense meshes in Europe and I receive several packets per second.

So an idle timeout of, in your case, 30 mins that in my case would have been valid in September or summer would never trigger today.

As such IMHO it would be better to have an algorithm compute the best idle interval and have it adjust automatically on changing conditions. New repeaters pop up every single day.

ps. I think ill configured repeaters doing flood adverts at short intervals are the primary cause of the many packets per second, and putting a strain on the mesh. Reducing the number of configuration options could be beneficial as well.

Idle time is how long the CPU should be idle and then wake up for doing stuff, or it is waken before throw an rx. After a Rx/tx I have measured on my own a fast falloff of the current and on Rx a short time normal state, repeat, idle again.

@fschrempf
Copy link
Contributor Author

@4np I think you misread the PR. The idle interval just determines how long the CPU will be in idle and therefore no processing of outgoing packets, serial user input or UI takes place. This interval can be interrupted at any time by incoming packets.

The time after which the idling starts is hardcoded to 3 minutes after startup and 5 seconds during normal operation.

So the worst that will happen in a busy mesh is that it will never go to idle because there are always incoming packets within the 5 second interval. But it will not affect the performance of the repeater or anything.

@4np
Copy link

4np commented Dec 31, 2025

Thank you for the clarification @fschrempf and @SaschaKt 👍

There is no reason to not use the reset pin as the RAK4630/31 module
has it connected internally.

Signed-off-by: Frieder Schrempf <[email protected]>
This makes the code easier to read and allows for easier changing of
the hardcoded values.

Signed-off-by: Frieder Schrempf <[email protected]>
When a CLI command is issued through the serial interface, extend the
timeout for going to sleep to give the user more time for issuing more
commands.

Signed-off-by: Frieder Schrempf <[email protected]>
This uses the core functions suspendLoop() and resumeLoop() to suspend
the main task and put the CPU in a low power idle mode.

The wakeup occurs either through the specified timeout using a timer
interrupt or through an RX interrupt from the radio module.

Signed-off-by: Frieder Schrempf <[email protected]>
@fschrempf
Copy link
Contributor Author

fschrempf commented Jan 2, 2026

With #1266 being merged in dev, this has now been reworked to work on top of this. I will let the new version run on my RAK4630 test repeater. Here is a link to the repeater firmware archive from the CI for any of you who want to do another test themselves.

As a reminder, the new version requires powersaving to be turned on via CLI command powersaving on.

@fschrempf fschrempf changed the title Repeater CPU Idling NTF52 Repeater Powersaving Jan 2, 2026
@fschrempf fschrempf changed the title NTF52 Repeater Powersaving NRF52 Repeater Powersaving Jan 2, 2026
@IoTThinks
Copy link

IoTThinks commented Jan 3, 2026

The PR looks great.
I will do the test for this PR for my RAK4631 and T-Echo Lite next week.

Next week, I will push a PR to enable powersaving for ALL ESP32-based board too.
To do this, I also add 1 similar line for ESP32 in setFlag like you.
I guess the moderators will review that line in setFlag very carefully.

Hope we can have PowerSaving for both NRF52 and ESP32 repeaters soon.

@4np
Copy link

4np commented Jan 4, 2026

Here is a link to the repeater firmware archive from the CI for any of you who want to do another test themselves.

It's running on my spare RAK based repeater and so far all seems to be working well.

@fschrempf
Copy link
Contributor Author

fschrempf commented Jan 4, 2026

@4np Thanks for testing. On my repeater it seems like the node becomes unresponsive after some time. It might be unrelated, but I need to do some debugging.

@4np
Copy link

4np commented Jan 5, 2026

@fschrempf this morning the repeater has become unresponsive for me as well, so there does appear to be some genuine issue.

It looks like on nRF52 platforms a timer callback runs in the interrupt/scheduler context, not in the main loop.

Another concern could be that resumeLoop() from setFlag() may be executing from a different context as well (setFlag() in RadioLib wrappers is commonly called from a DIO interrupt / radio IRQ path), although it looks like Adafruit's resumeLoop() handles that so that should not be a concern:
image

Also, the wake by RX logic does not cancel the timer so it keeps on running.

Lastly, it may be preferable to safeguard against invalid interval values. Maybe clamp them between a min and a max value (max(min(maximum_number_of_secs, sec), minimum_number_of_seconds))).

Copy link

@4np 4np left a comment

Choose a reason for hiding this comment

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

As the radio appears to become unresponsive after a period of low RX (overnight, at least in my case) it may be that the timer wasn’t actually armed (create/start/reset failed or got wedged), but suspendLoop() is still getting called. With no RX there’s no other wake source and the repeaters stays suspended forever.

However, it also does not wake on RX so it may seem to point to the RX interrupt not firing at all? It looks like this may happen when the loop is suspended while the IRQ isn’t serviced/cleared, causing it to stay locked and resulting in the RX interrupt not being executed.

startup_reason = BD_STARTUP_NORMAL;
}

void NRF52Board::sleep(uint32_t secs) {
Copy link

@4np 4np Jan 5, 2026

Choose a reason for hiding this comment

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

Psuedo code (I am not sure about the PIN numbers):

// NRF52Board.cpp (file scope)

static SoftwareTimer sleep_timer;
static bool sleep_timer_inited = false;
static volatile bool sleeping = false;

static void sleep_timer_callback(TimerHandle_t) {
  sleeping = false;
  resumeLoop();
}

void NRF52Board::sleep(uint32_t secs) {
  if (secs == 0) return;

  // Convert seconds->ms with overflow clamp
  uint64_t ms64 = (uint64_t)secs * 1000ULL;
  uint32_t ms = (ms64 > 0xFFFFFFFFULL) ? 0xFFFFFFFFUL : (uint32_t)ms64;

  // Helper: detect SX1262 DIO1 asserted (pending/latched IRQ).
  auto dio1_high = []() -> bool {
#if defined(PIN_LORA_DIO1)
    return digitalRead(PIN_LORA_DIO1) == HIGH;
#elif defined(LORA_DIO1)
    return digitalRead(LORA_DIO1) == HIGH;
#else
    return false; // if unknown, we can't gate (better than breaking build)
#endif
  };

  // SX1262/RAK4630: if DIO1 is HIGH (IRQ latched), do NOT suspendLoop() or you can deadlock.
  if (dio1_high()) return;

  sleeping = true;

  // Cancel any previous schedule to avoid stale wake callbacks.
  sleep_timer.stop();

  // Initialize + (re)configure timer
  if (!sleep_timer_inited) {
    sleep_timer_inited = true;
  }
  sleep_timer.begin(ms, sleep_timer_callback, nullptr, /*repeating=*/false);
  sleep_timer.start();

  // Optional extra safety: re-check after arming timer to avoid a tiny race window
  // where an IRQ asserts DIO1 between the first check and suspendLoop().
  if (dio1_high()) {
    sleeping = false;
    sleep_timer.stop();   // don't leave a stray timer armed
    return;
  }

  suspendLoop();
}

void NRF52Board::wakeFromInterrupt() {
  // Optional micro-optimization: if we're not sleeping, no need to stop the timer.
  // But still safe to call resumeLoop() unconditionally.
  if (sleeping) {
    sleeping = false;
    sleep_timer.stop();   // prevent later spurious callback
  }
  resumeLoop();
}

So this should likely fix the unresponsive repeater issue:

  // SX1262/RAK4630: if DIO1 is HIGH (IRQ latched), do NOT suspendLoop() or you can deadlock.
  if (dio1_high()) return;

@fschrempf
Copy link
Contributor Author

As the radio appears to become unresponsive after a period of low RX (overnight, at least in my case) it may be that the timer wasn’t actually armed (create/start/reset failed or got wedged), but suspendLoop() is still getting called. With no RX there’s no other wake source and the repeaters stays suspended forever.

In my case, I think I saw it come back to life when trying a bit later. That makes me think the timer wakeup works correctly but the RX wakeup fails. But I'm not sure at all.

However, it also does not wake on RX so it may seem to point to the RX interrupt not firing at all? It looks like this may happen when the loop is suspended while the IRQ isn’t serviced/cleared, causing it to stay locked and resulting in the RX interrupt not being executed.

Hm, that could happen if we go to sleep with the interrupt being disabled. But I don't see why the interrupt would be disabled at that point. As far as I know everything runs synchronously and we are suspending the loop task from within the loop. The same flow has been working flawlessly with my previous implementation using a FreeRTOS semaphore to block the loop task (see fschrempf@7e7f091) which makes me think there might some other issue here.

@4np
Copy link

4np commented Jan 6, 2026

It became unresponsive for me again, during daytime / peak RF. I'm going to test some code changes on my end as well.

@4np
Copy link

4np commented Jan 6, 2026

I had another look and IMHO there are a few things that stand out:

  • Activity tracking: lastActive is currently updated in the main loop, but it should reflect when the radio last received an RX packet. Consider exposing a method in MyMesh for this (for example getLastRX() -> millis).
  • Loop resumption: In RadioLibWrappers, resuming the loop on RX makes sense, but it may be better to unify onRX in a single location (here or in MyMesh?)
  • Other considerations:
    • Disable sleep during OTA (otherwise BLE flashing will fail).
    • Postpone sleep on Serial activity (you already have this).
  • Variable names: Consider renaming for clarity (if found the current variable names a bit counter intuitive):
    // Interval of inactivity before sleeping
    constexpr unsigned long POWER_SAVING_TRIGGER_INTERVAL_SEC = 1 * 60; // 1 min
    // Duration of power-saving sleep
    constexpr unsigned long POWER_SAVING_SLEEP_DURATION_SEC = 30 * 60;  // 30 min
  • Probably the 5s work interval can just be removed, pseudo code:
    if (the_mesh.getNodePrefs()->powersaving_enabled &&
        the_mesh.millisHasNowPassed(the_mesh.getLastRX() + nextPowerSavingIntervalInSec * 1000),
        !the_mesh.hasPendingWork()) { ... }
  • Suggested approach: Use an onRX event handler to reset a non-activity timer that triggers sleep rather than doing the heavy lifting inside the main loop.

Thanks again for working on reducing power consumption! I think this will be a great addition, particularly for solar repeaters.

@IoTThinks
Copy link

  • Probably the 5s work interval can just be removed

True.
However, usually when there is a LoRa packet, another packet is coming, too.
So wakeup for 5s will surely reduce latency if any, especially during tracing.

@IoTThinks
Copy link

I have merged a minimum piece of this code.
Yes, it can achieve 6mA.

This check "digitalRead(PIN_LORA_DIO1) == HIGH" seems to fix the "stuck" problem.
Else if we keep "ping" the RAK4631 for a while, the board will be stuck at sleep mode.

BTW, we may need to move the definition of PIN_LORA_DIO1... into platform.ini instead.
Let me monitor it for a longer period.

The suspendLoop and resumeLoop are very neat approach.

@4np
Copy link

4np commented Jan 7, 2026

  • Probably the 5s work interval can just be removed

True. However, usually when there is a LoRa packet, another packet is coming, too. So wakeup for 5s will surely reduce latency if any, especially during tracing.

Yeah that makes sense.

BTW, we may need to move the definition of PIN_LORA_DIO1... into platform.ini instead.
Let me monitor it for a longer period.

I have some changes where I added a getLoRaDio1Pin method, and implemented it in all the NRF52 variants. I ran the changes for a while with these and I don't think it became unresponsive, but maybe I didn't try long enough. Something still wasn't right because it looked like the MESH_DEBUG_PRINTLN("Resuming work for %lu s", (unsigned long)POWER_SAVING_TRIGGER_INTERVAL_SEC); in main.cpp kept repeating; first 2 lines, then 4 lines, then more lines. Maybe millis() in main.cpp vs MyMesh values are unreliable? Maybe it should use the RTC instead? I didn't have time to investigate further, maybe if I have some spare time today.

Note: when the board starts sleeping, the MESH_DEBUG_PRINTLN that executed right before sleeping aren't flushed to the terminal so you only see them when the board wakes up again.

The core changes I made were these:

NRF52Board.h

  // Return the LoRa DIO1 GPIO pin used for radio IRQ/wake on this board.
  // Boards without a known LoRa DIO1 pin should return -1.
  // This is used to gate sleep to avoid deadlocks when SX126x keeps DIO1 asserted.
  virtual int16_t getLoRaDio1Pin() const { return -1; }

and I implemented getLoraDio1Pin() for all the NRF52 variants (except for minewsemi_me25ls01 is appears to always be P_LORA_DIO_1):

$ grep -r "getLoRaDio1Pin" --after-context=2
./variants/wio-tracker-l1/WioTrackerL1Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/wio-tracker-l1/WioTrackerL1Board.h-    return P_LORA_DIO_1;
./variants/wio-tracker-l1/WioTrackerL1Board.h-  }
--
./variants/xiao_nrf52/XiaoNrf52Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/xiao_nrf52/XiaoNrf52Board.h-    return P_LORA_DIO_1;
./variants/xiao_nrf52/XiaoNrf52Board.h-  }
--
./variants/heltec_mesh_solar/MeshSolarBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/heltec_mesh_solar/MeshSolarBoard.h-    return P_LORA_DIO_1;
./variants/heltec_mesh_solar/MeshSolarBoard.h-  }
--
./variants/wio_wm1110/WioWM1110Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/wio_wm1110/WioWM1110Board.h-    return P_LORA_DIO_1;
./variants/wio_wm1110/WioWM1110Board.h-  }
--
./variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h-    return LORA_DIO_1;
./variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h-  }
--
./variants/thinknode_m1/ThinkNodeM1Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/thinknode_m1/ThinkNodeM1Board.h-    return P_LORA_DIO_1;
./variants/thinknode_m1/ThinkNodeM1Board.h-  }
--
./variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h-    return P_LORA_DIO_1;
./variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h-  }
--
./variants/promicro/PromicroBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/promicro/PromicroBoard.h-    return P_LORA_DIO_1;
./variants/promicro/PromicroBoard.h-  }
--
./variants/mesh_pocket/MeshPocket.h:  int16_t getLoRaDio1Pin() const override {
./variants/mesh_pocket/MeshPocket.h-    return P_LORA_DIO_1;
./variants/mesh_pocket/MeshPocket.h-  }
--
./variants/rak_wismesh_tag/RAKWismeshTagBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/rak_wismesh_tag/RAKWismeshTagBoard.h-    return P_LORA_DIO_1;
./variants/rak_wismesh_tag/RAKWismeshTagBoard.h-  }
--
./variants/ikoka_stick_nrf/IkokaStickNRFBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/ikoka_stick_nrf/IkokaStickNRFBoard.h-    return P_LORA_DIO_1;
./variants/ikoka_stick_nrf/IkokaStickNRFBoard.h-  }
--
./variants/ikoka_handheld_nrf/IkokaNrf52Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/ikoka_handheld_nrf/IkokaNrf52Board.h-    return P_LORA_DIO_1;
./variants/ikoka_handheld_nrf/IkokaNrf52Board.h-  }
--
./variants/keepteen_lt1/KeepteenLT1Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/keepteen_lt1/KeepteenLT1Board.h-    return P_LORA_DIO_1;
./variants/keepteen_lt1/KeepteenLT1Board.h-  }
--
./variants/lilygo_techo/TechoBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/lilygo_techo/TechoBoard.h-    return P_LORA_DIO_1;
./variants/lilygo_techo/TechoBoard.h-  }
--
./variants/heltec_t114/T114Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/heltec_t114/T114Board.h-    return P_LORA_DIO_1;
./variants/heltec_t114/T114Board.h-  }
--
./variants/nano_g2_ultra/nano-g2.h:  int16_t getLoRaDio1Pin() const override {
./variants/nano_g2_ultra/nano-g2.h-    return P_LORA_DIO_1;
./variants/nano_g2_ultra/nano-g2.h-  }
--
./variants/lilygo_techo_lite/TechoBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/lilygo_techo_lite/TechoBoard.h-    return P_LORA_DIO_1;
./variants/lilygo_techo_lite/TechoBoard.h-  }
--
./variants/sensecap_solar/SenseCapSolarBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/sensecap_solar/SenseCapSolarBoard.h-    return P_LORA_DIO_1;
./variants/sensecap_solar/SenseCapSolarBoard.h-  }
--
./variants/rak4631/RAK4631Board.h:  int16_t getLoRaDio1Pin() const override {
./variants/rak4631/RAK4631Board.h-    return P_LORA_DIO_1; // GPIO 47
./variants/rak4631/RAK4631Board.h-  }
--
./variants/t1000-e/T1000eBoard.h:  int16_t getLoRaDio1Pin() const override {
./variants/t1000-e/T1000eBoard.h-    return P_LORA_DIO_1;
./variants/t1000-e/T1000eBoard.h-  }

The rest of the core changes I made:

NRF52Board.cpp

static bool sleep_timer_inited = false;
static volatile bool sleeping = false;
static bool ota_enabled = false;

...

static void sleep_timer_callback(TimerHandle_t) {
  // Timer callbacks run in the FreeRTOS timer task, not in loop().
  // Only resume the loop if we are actually sleeping.
  if (!sleeping) return;

  MESH_DEBUG_PRINTLN("Waking from sleep by timer callback");
  sleeping = false;
  resumeLoop();
}

void NRF52Board::sleep(uint32_t secs) {
  if (secs == 0) return;

  // Don't sleep when OTA is enabled.
  if (ota_enabled) {
      MESH_DEBUG_PRINTLN("Skip sleeping %lu s (OTA enabled)", (unsigned long)secs);
      return;
  }

  int16_t dio1 = getLoRaDio1Pin();
  if (dio1 < 0) {
    MESH_DEBUG_PRINTLN("Could not sleep %lu s (unknown LoRa DIO1 pin)", (unsigned long)secs);
    return;
  }

  uint64_t ms64 = (uint64_t)secs * 1000ULL;
  uint32_t ms = (ms64 > 0xFFFFFFFFULL) ? 0xFFFFFFFFUL : (uint32_t)ms64;

  auto dio1_high = [&]() -> bool {
    return digitalRead((uint8_t)dio1) == HIGH;
  };

  if (dio1_high()) {
    MESH_DEBUG_PRINTLN("Skipped sleeping %lu s (DIO1 is HIGH - pending/latched IRQ)", (unsigned long)secs);
    return;
  }

  // From this point on, we logically consider ourselves asleep
  sleeping = true;

  // Lazily initialize the sleep timer once, and reuse it.
  if (!sleep_timer_inited) {
    sleep_timer.begin(ms, sleep_timer_callback, nullptr, /*repeating=*/false);
    sleep_timer_inited = true;
  } else {
    sleep_timer.stop();
    sleep_timer.setPeriod(ms);
  }

  sleep_timer.start();

  if (dio1_high()) {
    MESH_DEBUG_PRINTLN("Skipped sleeping %lu s (DIO1 went HIGH after arming timer)", (unsigned long)secs);
    sleeping = false;
    sleep_timer.stop();
    return;
  }

  MESH_DEBUG_PRINTLN("Sleeping for %lu s", (unsigned long)secs);

  suspendLoop();

  // Defensive: should never reach here
  sleeping = false;
}

...

bool NRF52BoardOTA::startOTAUpdate(const char *id, char reply[]) {
  ota_enabled = true;

  // Disable sleeping then OTA is enabled.
  if (sleep_timer_inited) {
    sleep_timer.stop();
    sleeping = false;
  }
  ...
}

main.cpp

// Power saving (if enabled)
//
// The interval of no activity after which the board will sleep to save power.
constexpr unsigned long POWER_SAVING_TRIGGER_INTERVAL_SEC = 1 * 60; // 1 minute
// The sleep duration when the board has entered power saving sleep (or sooner on RX).
constexpr unsigned long POWER_SAVING_SLEEP_DURATION_SEC = 30 * 60;  // 30 minutes
// // When waking up from sleep, or when there is work pending, work a bit.
// constexpr unsigned long POWER_SAVING_WAKE_WORK_DURATION_SEC = 5;    // 5 seconds
// The interval (in sec) after which we will sleep (if powersaving is enabled).
unsigned long nextPowerSavingIntervalInSec = POWER_SAVING_TRIGGER_INTERVAL_SEC;

...
void loop() {
  ...
  if (the_mesh.getNodePrefs()->powersaving_enabled &&
      the_mesh.millisHasNowPassed(the_mesh.getLastRX() + nextPowerSavingIntervalInSec * 1000)) {
      // If we still have work to do, wait a little longer before sleeping.
      if (the_mesh.hasPendingWork()) {
        MESH_DEBUG_PRINTLN("Skipped powersaving (work pending, work another %lu s)", (unsigned long)POWER_SAVING_WAKE_WORK_DURATION_SEC);
        nextPowerSavingIntervalInSec += POWER_SAVING_WAKE_WORK_DURATION_SEC;
      } else {
        MESH_DEBUG_PRINTLN("Start powersaving");

        // Sleep and wake up after the sleep duration, or when receiving a LoRa packet.
        board.sleep(POWER_SAVING_SLEEP_DURATION_SEC);

        MESH_DEBUG_PRINTLN("Resuming work for %lu s", (unsigned long)POWER_SAVING_TRIGGER_INTERVAL_SEC);
        nextPowerSavingIntervalInSec = POWER_SAVING_TRIGGER_INTERVAL_SEC;
      }
  }
}

MyMesh.h

  unsigned long lastRX; // The last time a RX event happened.
  ...
    
  public:
  ...
  unsigned long getLastRX() {
    return lastRX;
  }

And in MyMesh.cpp I set lastRX = millis(); where RX happened. However, probably it's better to use RadioLibWrapper's setState for this when there is RX.

Hopefully this will be helpful to move this PR along :)

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.

6 participants