Deep Sleep & Battery Power¶
An ESP32 running full-tilt draws 80-100+ mA — a fresh 18650 battery lasts
a day, maybe two. The same board in deep sleep draws single-digit
microamps, stretching that to months. machine.deepsleep() is how
battery-powered MicroPython projects survive: wake on a timer or a pin,
do the minimum work, sleep again. This module covers deep sleep, wake
sources, surviving a full reset with RTC memory, measuring sleep current,
and budgeting battery life — logic and math run via python3; the
machine/esp32 sleep APIs are reviewed against MicroPython docs since
Wokwi doesn't model microamp-level current draw.
Deep sleep is not a pause¶
This is the single most important fact in this module: deep sleep resets the chip. Your program does not resume where it left off — it restarts from the top, exactly like a power-cycle. Variables, open sockets, WiFi connections, everything in RAM: gone.
import machine
import time
print("Awake! Doing work...")
time.sleep(1) # stand-in for real sensor work
print("Going to sleep for 30 seconds")
machine.deepsleep(30_000) # milliseconds; board resets after this
# nothing after this line ever runs — the reset happens inside the call
Compare light sleep (machine.lightsleep()), which does preserve RAM
and resumes execution after the call returns — much lower savings, but no
state loss. Deep sleep is the big win; light sleep is the "I still need my
variables" compromise.
Wake sources¶
Timer (most common)¶
External pin (EXT0 — single pin)¶
import machine
from machine import Pin
# Wake when pin 27 goes HIGH (a button pulling it up, for example)
wake_pin = Pin(27, Pin.IN, Pin.PULL_DOWN)
esp32.wake_on_ext0(pin=wake_pin, level=esp32.WAKEUP_ANY_HIGH)
machine.deepsleep()
(import esp32 for the esp32.wake_on_ext0/wake_on_ext1/WAKEUP_*
names.) EXT1 wakes on any of several pins at once, useful for multiple
buttons:
import esp32
from machine import Pin
pins = (Pin(27, Pin.IN, Pin.PULL_DOWN), Pin(33, Pin.IN, Pin.PULL_DOWN))
esp32.wake_on_ext1(pins=pins, level=esp32.WAKEUP_ANY_HIGH)
machine.deepsleep()
Touch pins¶
Finding out why you woke up¶
Since the program restarts from scratch every time, check the wake reason at the top of your script to branch behavior:
import machine
reset_cause = machine.reset_cause()
wake_reason = machine.wake_reason()
if reset_cause == machine.DEEPSLEEP_RESET:
print("woke from deep sleep, reason:", wake_reason)
if wake_reason == machine.PIN_WAKE:
print("woken by a pin")
elif wake_reason == machine.TIMER_WAKE:
print("woken by the timer")
else:
print("normal power-on / hard reset")
Surviving the reset: RTC memory¶
A small chunk of RAM survives deep sleep (though not a full power loss):
machine.RTC().memory(). It only stores bytes, so counters/small state
need encoding:
import machine
rtc = machine.RTC()
def load_count():
data = rtc.memory()
return int(data) if data else 0
def save_count(n):
rtc.memory(str(n).encode())
count = load_count() + 1
print("boot count:", count)
save_count(count)
machine.deepsleep(10_000)
For structured state, ujson.dumps()/loads() into that same byte
buffer works, as long as it fits (RTC memory is small — a few hundred
bytes budget, not KB).
Battery budgeting¶
Rough math, done here in plain Python since it's just arithmetic:
def battery_life_hours(battery_mah, active_ma, active_s, sleep_ua, sleep_s):
cycle_s = active_s + sleep_s
active_mah_per_cycle = active_ma * (active_s / 3600)
sleep_mah_per_cycle = (sleep_ua / 1000) * (sleep_s / 3600)
mah_per_cycle = active_mah_per_cycle + sleep_mah_per_cycle
cycles = battery_mah / mah_per_cycle
return cycles * cycle_s / 3600
# 2000 mAh battery, 120 mA while awake for 2 s, 10 uA asleep for 58 s
hours = battery_life_hours(2000, 120, 2, 10, 58)
print(f"{hours:.0f} hours (~{hours/24:.0f} days)")
The lesson the numbers teach: active current dominates unless the duty cycle (awake time / total cycle time) is tiny. Waking every 60 s for 2 s of work (3.3% duty cycle) still costs far more battery than waking every 10 minutes for the same 2 s — sleep longer, not just lower-power, when the application allows it.
Deep sleep gotchas¶
Global setup code runs every wake, not once
Anything at module level — WiFi connect, sensor init, driver imports — runs again after every wake, because the "boot" is a full restart. Budget the time and current cost of your setup path; a 3-second WiFi reconnect every wake can dwarf the sleep savings you're chasing.
Peripherals lose power state
Deep sleep can power down most of the chip, including GPIO drive in some configurations. Don't assume a pin you set HIGH before sleeping is still HIGH after waking — reconfigure peripherals from scratch each boot.
deepsleep() with no argument sleeps forever (until a wake source fires)
machine.deepsleep() with no duration only wakes via a configured
esp32.wake_on_* source — if you forgot to configure one, the board
sleeps until manually reset. Always pass a timer duration as a
fallback, or double-check a wake source is armed.
USB serial goes away during sleep
You will not see print() output appear "live" during a sleep/wake
cycle over USB in Wokwi or on some real boards — the console
reconnects after the reset. Log to RTC memory or flash if you need to
debug a stretch of missed wakeups.
How It Actually Works¶
Deep sleep isn't the VM "pausing" — it's the chip's power-management unit physically shutting off entire power domains, which is exactly why nothing in RAM survives it except a tiny, deliberately isolated region.
- Deep sleep powers down the CPU cores, most SRAM, and most peripherals,
keeping only the RTC (real-time clock) domain and a small dedicated slice
of memory alive on a separate, minimal power rail. That's the actual,
physical reason
machine.RTC().memory()exists as a special, tiny buffer rather than "just don't clear normal RAM": the ESP32's ULP/RTC domain is wired to its own low-power supply specifically so it can stay energized at microamp draw while the main SRAM (which needs orders of magnitude more power just to hold its charge state) is fully powered off. There's no software trick that could preserve ordinary heap objects through this — the transistors holding those bits simply lose power. - Waking from deep sleep is architecturally a boot, because it is one.
The RTC domain's wake logic (a timer counter, or a level-change detector
on the EXT0/EXT1 pins) asserts a reset line to the main CPU exactly the
way the power-on reset circuit does — the boot ROM runs, the MicroPython
firmware image is reloaded and re-executed from its entry point, your
module-level code runs again top to bottom.
machine.wake_reason()andreset_cause()exist purely because the ROM bootloader can distinguish why it's running this boot (power-on vs. deep-sleep-timer vs. deep-sleep-pin) by reading a status register the RTC controller sets before triggering the reset — information your Python code would otherwise have no way to recover. - "Peripherals lose power state" follows from the same power-domain
boundary. GPIO pin drive strength and output-hold logic mostly live
outside the always-on RTC domain, so a pin you drove HIGH before sleeping
reverts to its default (often floating or pulled per the IO_MUX reset
value) the instant its power domain drops — there's no register anywhere
remembering "this pin was high" across the power gap unless you
specifically used
esp32's hold-pin APIs, which route that one pin's state through the RTC domain instead of the main one. - The battery-life math bears out a hardware reality: switching power domains on and off has its own energy cost (charging up regulators, re-associating WiFi, re-initializing sensors), so very short sleep intervals can spend more energy on wake-up transients than they save in sleep current — a genuinely low duty cycle needs long uninterrupted sleep stretches specifically because every wake pays that fixed re-initialization tax before any useful "awake" work even starts, which is why the exercise's comparison across 15 s / 60 s / 10-minute intervals is really measuring how thoroughly that fixed cost gets amortized.
Cheat sheet¶
| Function / idiom | Purpose |
|---|---|
machine.deepsleep(ms) |
Sleep and reset; program restarts from the top |
machine.lightsleep(ms) |
Sleep, keep RAM, resume after the call |
machine.reset_cause() |
DEEPSLEEP_RESET, PWRON_RESET, etc. |
machine.wake_reason() |
PIN_WAKE, TIMER_WAKE, TOUCHPAD_WAKE |
esp32.wake_on_ext0(pin, level) |
Wake on one pin |
esp32.wake_on_ext1(pins, level) |
Wake on any of several pins |
esp32.wake_on_touch(True) |
Wake on a touch pin |
machine.RTC().memory(bytes) / .memory() |
Small state store that survives deep sleep |
| duty cycle = active_s / (active_s + sleep_s) | Drives battery life more than raw sleep current |
Exercise¶
Build a battery-aware logger (logic testable in plain python3, sleep
calls reviewed against docs): on each boot, read machine.reset_cause()
and machine.RTC().memory() to recover a boot counter (0 if this is a
cold boot). Print "boot #N, cause=..., reason=...". Simulate a sensor
read, save the incremented counter to RTC memory, then call
machine.deepsleep(15_000). Separately, write a plain-Python function
battery_life_hours(battery_mah, active_ma, active_s, sleep_ua, sleep_s)
(as above) and use it to compare three duty cycles — wake every 15 s, every
60 s, and every 10 minutes, same 2 s of active work each time — printing
projected battery life in days for a 2000 mAh cell at each interval.