A Pico 2 W sitting on Wi-Fi draws about 38 mA. That’s fine on a USB lead, but it’s hopeless for a sensor running on a battery or a small solar panel. Pico SDK 2.3.0 promised to fix that with a new low power library, and I wanted to see if it really could.
It can. My little IoT node now drops to 3.5 mA while it sleeps between readings. It just wasn’t quite as easy as it should have been, because two niggling SDK bugs got in the way once I added Wi-Fi. In this post I’ll walk through the whole journey from 38 mA to 3.5 mA, then show you both bugs and how I worked round them without patching the SDK.
Video: I Got Pico 2 W Down to 3.5mA – Then the SDK Broke It · Code: github.com/DrJonEA/ThingsBoard-HTTP (folder exp/lowpowerHTTP)
Why low power was on my “too hard” list
I’ve looked at deep sleep and dormant mode on the Pico before, in How to Hibernate the Raspberry Pi Pico in C/C++ and How to Deep Sleep the Raspberry Pi Pico. I got it working, but it was hard to do, easy to get wrong and very painful to debug. Recovering cleanly from dormant was the tricky part, and doing it under FreeRTOS made it worse.
Pico SDK 2.3.0 changes that. It wraps all of that difficult recovery code in a library, pico_low_power, and it works with the FreeRTOS kernel. There are three functions to know:
low_power_sleep_for_ms(): a light sleep.low_power_dormant_for_ms(): dormant, the lowest power state, woken by the always on (AON) timer.low_power_pstate_for_ms(): for controlling which power domains stay up.
My first test was simply to drop a FreeRTOS application into dormant for 20 seconds:
"pico/low_power.h"clock_dest_bitset_t clocks = clock_dest_bitset_none();low_power_dormant_for_ms(20 * 1000, DORMANT_CLOCK_SOURCE_LPOSC, &clocks);
The meter dropped to 3.5 mA, the Pico woke up, and FreeRTOS carried on as if nothing had happened. That’s exactly what I wanted to see. Then I added web services.
The test rig: batch telemetry to ThingsBoard
I wanted a realistic battery or solar node, so the Pico 2 W reads its on-chip temperature sensor and sends the readings to ThingsBoard, the IoT platform I’ve been using in my last few videos. ThingsBoard has a nice HTTP API, and the bit that matters for low power is batch telemetry: you can post several readings in one request, each with its own timestamp.
That gives us a simple pattern for a battery node:
- Join Wi-Fi once and sync the time with SNTP.
- Drop Wi-Fi.
- Take a reading, timestamp it, store it, sleep. Repeat.
- When the batch is full (six readings in my example, but it could be several hundred), join Wi-Fi, post the whole batch in one go, and drop Wi-Fi again.
Knowing the time is key here. If the readings are sent later, each one has to carry the time it was taken, so the SNTP sync at the start isn’t optional. If you’d like the comparison between ThingsBoard’s HTTP and MQTT APIs for this kind of device, I covered it in ThingsBoard HTTP vs MQTT: Which is Better for Low Power?.
The power ladder: 38 mA to 3.5 mA

I measured every stage with a multimeter in series with the supply. One thing I learnt straight away: my usual meter on its 200 mA range couldn’t supply enough current for the Pico 2 W to connect to Wi-Fi at all. The connection burst is too big for it. I’ve seen that before, and it tells you the peak is really high, even if I can’t put a number on it. So thank you to my friend Nick for lending me a meter with a 400 mA range, which is what I used for these readings.
Here’s how the ladder came out:
| Stage | Current |
|---|---|
| Joining Wi-Fi (after the initial burst) | about 60 mA |
| Online: reading, posting over HTTP | 38 mA |
| Batch mode: Wi-Fi dropped, CYW43 still powered | about 30 mA |
| CYW43 powered off, sampling with the ADC | 16 mA |
| Dormant between readings | 3.5 mA |
Step 1: always online, 38 mA
My simple HTTP example stays connected the whole time. It sits at 38 mA whether it’s reading the sensor or posting.
Step 2: batch mode, 30 mA
The batch example drops off Wi-Fi between posts, which only gets us down to a marginal 30 mA. Why still so high? Because the CYW43 Wi-Fi chip is still powered, even in its power saving mode. And there’s a price to pay: every time we reconnect we get that connection peak again, then 38 mA while we post. Batching only wins if the time spent sampling is long compared to the time spent connecting and posting.
Step 3: radio off and dormant, 3.5 mA

The low power example keeps the same cycle but changes what happens in the middle. Instead of just leaving the Wi-Fi idle, it powers the CYW43 chip right down, so sampling with the ADC costs 16 mA. Then, instead of a normal sleep between readings, it drops the RP2350 into dormant at 3.5 mA.
You still pay for the connection at the start to get the time, and again each time you post the batch. But the bit in between, which is where a sensor node spends nearly all its life, is now a fraction of what it was. Without powering down the CYW43 you won’t get anywhere near 3.5 mA.
The code
The main task is short. Connect, start SNTP, wait for the time, turn Wi-Fi off, then loop: read, store, go dormant, and post when the batch is full.
// Wait for SNTP to sync so we can timestamp readingswhile (!aon_timer_is_running()) { vTaskDelay(2000);}// Turn Wi-Fi off, but leave lwIP alone (see bug 2)cyw43_arch_lwip_begin();cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);cyw43_arch_lwip_end();cyw43_arch_disable_sta_mode();vTaskDelay(1000);cyw43_driver_deinit(cyw43_arch_async_context());for (;;) { for (int i = 0; i < NUM_SENSOR_READINGS; i++) { // read the temperature, timestamp it from the AON timer, store it aon_timer_get_time(&readings[i].ts); ... low_power_dormant_for_ms(10 * 1000, DORMANT_CLOCK_SOURCE_LPOSC, &clocks); } // Wake the radio, post the batch, power it down again cyw43_driver_init(cyw43_arch_async_context()); if (WifiHelper::join(WIFI_SSID, WIFI_PASSWORD, 3)) { sendSensorData(NUM_SENSOR_READINGS); } ... // same Wi-Fi off sequence as above}
That Wi-Fi off sequence looks more convoluted than it should be. The last line really ought to be cyw43_arch_deinit(), and it isn’t. That’s down to the second bug.
Bug 1: FreeRTOS static allocation races the CYW43 clean-up

You need the FreeRTOS kernel for sockets, and you need sockets for the HTTP libraries. In my standard FreeRTOS configuration I turn on both static and dynamic allocation. They’re both useful.
The trouble is the SDK really likes static allocation. With it enabled, cyw43_arch_init() uses static memory. When you call cyw43_arch_deinit(), it clears that memory with a memset to zero. Sometimes, but not always, it does that before the FreeRTOS kernel has finished cleaning up the task that lived there. When that happens you get a hard fault inside uxListRemove().
It’s a race condition, so it doesn’t happen every time, but it happens a lot. That makes it painful to debug.
The workaround: turn static allocation off and use dynamic allocation only, in FreeRTOSConfig.h:
configSUPPORT_STATIC_ALLOCATION configSUPPORT_DYNAMIC_ALLOCATION
That’s fine for most of my projects. Not all of them, but most.
Bug 2: lwIP context deadlock when Wi-Fi comes back

To get down to 16 mA, and then 3.5 mA, we have to take the CYW43 chip and its service offline completely, then bring them back each time we post. The obvious way is cyw43_arch_deinit() followed later by cyw43_arch_init().
The problem is in lwIP’s FreeRTOS init and deinit functions. There’s an lwIP context that isn’t handled properly between them, so it’s never restored. When you initialise again, the code waits for that context to become valid, and it never does. Your program behaves very oddly: it just sits there trying to connect to the network, then times out.
The workaround: don’t call the top level cyw43_arch_deinit(). Drop down to the driver instead:
- Drop the connection:
cyw43_wifi_leave()(insidecyw43_arch_lwip_begin()andcyw43_arch_lwip_end()). - Disable the service:
cyw43_arch_disable_sta_mode(). - Disable the chip:
cyw43_driver_deinit(cyw43_arch_async_context()).
The driver deinit needs a context, but we’re using the standard one, so cyw43_arch_async_context() provides it. To come back online, cyw43_driver_init() with the same context. The key point is that this leaves the lwIP context alone. It isn’t wiped on the way down, so it doesn’t need restoring on the way up. It works very nicely.
Bonus gotcha: dormant starts the always on timer

This one was my bug, not the SDK’s. When you go dormant, the always on timer is what wakes the RP2350 up, so going dormant starts the AON timer. My SNTP code waited for aon_timer_is_running() to tell me the time had been set. After that first test dormant, it was already running, so my code thought the time was synced when it wasn’t.
The fix is to stop the AON timer before you start SNTP:
aon_timer_stop(); // dormant leaves it running...while (!aon_timer_is_running()) { vTaskDelay(2000); // now this really means SNTP has synced}
What about SDK 2.3.1?

While I was making the video, Pico SDK 2.3.1 came out with a whole load of bug fixes. I was both excited and slightly annoyed, because it meant retesting everything. So, did it fix either bug? No. Both bugs, as I’ve described them, still happen on 2.3.1. There are bug reports open for both. The release does touch code around these areas and makes some significant improvements to race conditions and memory handling, so I’m hopeful, but for now you still need both workarounds.
The repo has a separate exp/lowpowerHTTP-2.3.1 folder built against 2.3.1, with the AON timer fix in main().
It was a useful exercise anyway, because retesting is how I found my own AON timer mistake. Oh well, these bugs happen.
A note on security
My node talks to ThingsBoard over plain text HTTP. That’s fine on the bench, but it isn’t good enough for production. This video was sponsored by wolfSSL, and their TLS library would be my go-to way to fix that: a secure socket connection for the HTTP to run over. With their examples and documentation, adding security to a Pico project is straightforward.
What this opens up
Low power sleep and dormant on the Pico were on my “too hard” list until this version of the SDK. Now they’re doable. Yes, there are bugs to work round, particularly for the IoT use case, and I suspect these are the ones you’ll hit on your own projects too. But they’re not hard to work round, and there’s no patching the SDK, which I really didn’t want to show you.
For me this opens up a bunch of solar and battery projects: revisiting my weather station, irrigation systems, and other remote sensors.
Quick checklist if you’re trying this yourself:
- Use Pico SDK 2.3.0 or later and link
pico_low_powerandpico_aon_timer. - Sync the time with SNTP once, and batch your readings with timestamps.
- Set
configSUPPORT_STATIC_ALLOCATION 0in FreeRTOS (bug 1). - Power the radio down with
cyw43_driver_deinit(), notcyw43_arch_deinit()(bug 2). - Call
aon_timer_stop()before waiting for SNTP. - Use a meter range that can supply the Wi-Fi connection burst.
What would you build with a 3.5 mA Pico 2 W? Tell me in the comments on the video. I read every one.
Get my videos first
Channel members see every new video before it goes public, so they had this one weeks before everyone else. Membership also helps me keep making these deep dives and get the droids to Open Sauce. If you’d like to join, you can sign up here.
This post contains affiliate and sponsor links (ThingsBoard, wolfSSL). They cost you nothing extra and help keep the channel running.

Thank you for this article – I really appreciate the style and detailed presentation.
Thank you. Glad you are finding them useful