The ESP32 is one of the most capable and popular microcontrollers for battery-powered IoT projects. It integrates WiFi, Bluetooth, dual cores, and a rich peripheral set — all of which are significant power consumers. The key to long battery life is keeping the ESP32 in its deepest sleep mode for as long as possible between useful work periods.
The ESP32 has five power modes, ordered from highest to lowest power consumption:
| Mode | CPU | WiFi/BT | RTC | Typical Current |
|---|---|---|---|---|
| Active | Running | Active | On | 160–240 mA |
| Modem Sleep | Running | Off | On | 20–30 mA |
| Light Sleep | Paused | Off | On | 0.8–1.5 mA |
| Deep Sleep | Off | Off | On | 10–150 µA |
| Hibernation | Off | Off | Minimal | 5–10 µA |
Modem sleep: The WiFi/BT radio is disabled between DTIM beacon intervals. The CPU continues running. Useful for applications that need to process data continuously but only use WiFi intermittently.
Light sleep: CPU clock is gated, peripherals are paused, and RAM content is preserved. Wake latency is very low (~1 ms). Good for applications that wake frequently (every few seconds) and need to resume immediately.
Deep sleep: The main CPUs, most peripherals, and RAM are powered down. Only the RTC controller, RTC memory (16 KB slow + 8 KB fast), and RTC peripherals remain active. Wake latency is ~300–500 ms (includes boot sequence).
Hibernation: Same as deep sleep but the internal 8 MHz oscillator is also stopped. Only an external 32 kHz crystal timer or EXT0/EXT1 GPIO can wake from hibernation. Lowest power, longest wake latency.
When the main system powers down in deep sleep, the following survive:
RTC_DATA_ATTR in C, or stored in machine.RTC().memory() in MicroPythonesp_sleep_get_wakeup_cause() after wakingUse RTC memory to store state that must persist across sleep cycles — boot count, measurement history, configuration flags.
Multiple wake sources can be configured simultaneously. The first to trigger wakes the device.
Timer wake (most common for sensor nodes):
import machine
# MicroPython: sleep for 60 seconds
machine.deepsleep(60 * 1000) # milliseconds
External GPIO wake (EXT0): Wakes when a single RTC-capable GPIO reaches a specified level. Useful for push-button wake or interrupt from a sensor’s data-ready pin.
External GPIO wake (EXT1): Monitors up to 8 RTC GPIOs simultaneously using an AND or OR logic across all pins. More flexible than EXT0.
Touch pad wake: The ESP32’s capacitive touch pads can wake from deep sleep when touched. Useful for wearable or interface-light devices.
ULP co-processor wake: The Ultra-Low Power co-processor (ULP) continues running during deep sleep. It can read ADC values, set GPIOs, and decide whether to wake the main cores. Covered in Section 4.5.
For a sensor node that wakes periodically, takes a measurement, transmits via WiFi, and returns to sleep, the average current is:
I_avg = (I_active × t_active + I_sleep × t_sleep) / (t_active + t_sleep)
Worked example — temperature sensor transmitting every 10 minutes:
I_avg = (200 mA × 4 s + 0.02 mA × 596 s) / 600 s
= (800 + 11.92) / 600
= 1.35 mA
On a 1000 mAh LiPo (assuming 80% usable capacity = 800 mAh):
Runtime = 800 mAh / 1.35 mA ≈ 593 hours ≈ 24 days
Reducing active time from 4 s to 2 s (e.g., by using a static IP instead of DHCP) drops I_avg to 0.70 mA and extends runtime to 47 days.
The ULP (Ultra-Low Power) co-processor is a small FSM-based or RISC-V processor (depending on ESP32 variant) that runs from RTC power while the main cores sleep. It can:
A typical use case: the ULP reads a sensor every minute, stores the value in RTC memory, and only wakes the main CPU when the value exceeds a threshold. This avoids the expensive WiFi connect + transmit cycle for normal readings.
The ULP consumes roughly 25–50 µA when running, compared to 10–20 µA for deep sleep without ULP. For applications where ULP runs at a low duty cycle, the net average current can remain very low.
import machine, esp32, time
# Store boot count in RTC memory
rtc = machine.RTC()
data = rtc.memory()
boot_count = int.from_bytes(data[:4], 'little') + 1 if data else 1
rtc.memory(boot_count.to_bytes(4, 'little'))
print(f"Boot #{boot_count}")
# ... take measurement, transmit ...
# Configure wake source and enter deep sleep (30 seconds)
machine.deepsleep(30_000)
The full demo including WiFi connect and INA219 measurement is in code/deep_sleep_demo.py.
Leaving WiFi on before sleep: The radio must be explicitly stopped before deep sleep or it will continue drawing current during the sleep entry sequence. Call sta_if.disconnect() and sta_if.active(False) before machine.deepsleep().
Floating GPIO causing leakage: Undriven pins with input pull-resistors disabled can leak several milliamps. Either enable pull-up/pull-down resistors or isolate the pins via load switches before sleeping.
Writing to flash every wake cycle: Flash writes are slow and increase active time. More critically, flash has a limited write endurance (~100,000 cycles). Writing a measurement to flash 6 times per hour = 52,560 writes/year. Buffer in RTC memory and write to flash only every N cycles.
Using time.sleep() instead of machine.deepsleep(): time.sleep() keeps the CPU running. It reduces nothing. Always use machine.deepsleep() for battery-powered applications.
| ← Chapter 3: Power Fundamentals and Voltage Regulators | Table of Contents | Chapter 5: Arduino and AVR Sleep Modes → |