gwordal

Lesson 5 of 5 · 20 min

Deep sleep and the power budget

An ESP32 with Wi-Fi switched on draws roughly as much current as five LEDs, around 80 to 100 mA on average. Run it from a 2000 mAh battery and it is flat in about a day. Yet sensor nodes that last a year on that same battery exist. The trick is not a bigger battery or a clever chip. It is spending nearly all of the time asleep, and being able to calculate exactly what that buys you.

The power states

The ESP32 can step down through several power modes, each switching off more of the chip:

StateWhat is onTypical current
Wi-Fi transmittingEverything, radio at full power160 to 260 mA peaks
Active, radio idleCores at 240 MHz30 to 70 mA
Light sleepCPU paused, RAM keptabout 0.8 mA
Deep sleepOnly the RTC controller and RTC memoryabout 10 µA
HibernationOnly the RTC timerabout 5 µA

Between active and deep sleep lies a factor of 50 mA / 0.010 mA = 5000. In deep sleep the CPU, the main SRAM and the radio are all powered down. That is why nothing in normal RAM survives, and why waking up is a full reboot.

The budget formula

A battery stores charge in mAh. A current of 1 mA for 1 hour uses 1 mAh. So the runtime is:

hours = capacity_mAh / average_current_mA

When a device alternates between two states, the average current is the time-weighted mean, which is the same as total charge per cycle divided by the cycle length:

I_avg = (I_active x t_active + I_sleep x t_sleep) / T

Take a node that wakes every 10 minutes (T = 600 s), stays awake 3 s (about 2 s to join Wi-Fi, the rest to read and publish) at 120 mA, and sleeps for 597 s at 0.010 mA:

I_avg = (120 x 3 + 0.010 x 597) / 600 = (360 + 5.97) / 600 = 0.61 mA

On a 2000 mAh battery: 2000 / 0.61 = 3279 h, which is about 137 days. The same battery with Wi-Fi always on, at 80 mA average, gives 2000 / 80 = 25 h.

A calculation table

All rows use a 2000 mAh battery, 120 mA while awake and 0.010 mA asleep:

StrategyAwake per cycleAverage currentBattery life
Always on, no sleepcontinuous80 mA25 h (1 day)
Wake every 1 min3 s6.01 mA333 h (14 days)
Wake every 10 min3 s0.610 mA3279 h (137 days)
Wake every 10 min, fast connect1 s0.210 mA9525 h (397 days)
Wake every 60 min3 s0.110 mA18183 h (758 days)

Look at the last row. In one hour the node spends 360 mA-seconds awake and only 0.010 x 3597 = 36 mA-seconds asleep, so 91% of the energy goes to the 3 seconds awake. Making the sleep current even lower would barely help. Shortening the awake time is far more effective, which is the reason for the fast-connect row: caching the access point's channel and BSSID, and using a static IP, cuts the connect from about 2 s to well under 1 s by skipping the scan and DHCP.

Wake-up sources

Something must end the sleep. The ESP32 offers several sources:

  • Timer: esp_sleep_enable_timer_wakeup(us) wakes after a set time in microseconds.
  • ext0: one RTC GPIO pin wakes the chip on a chosen level.
  • ext1: several RTC GPIO pins, waking on any high or on all low.
  • Touch pad: a capacitive touch pin.
  • ULP coprocessor: a tiny processor that keeps running during sleep and can read a sensor, waking the main cores only when a value crosses a threshold.

Only RTC GPIOs (such as 0, 2, 4, 12 to 15, 25 to 27 and 32 to 39) can wake the chip. Note that the timer uses microseconds: 600 s = 600,000,000 us, which overflows a 32-bit int (maximum about 2.1 billion, only 35 minutes). Use uint64_t and the ULL suffix.

The timer runs from the internal RTC clock, which drifts by a few percent with temperature, so do not rely on it for precise timekeeping.

What survives a sleep

After waking, setup() runs again from the top, as after a reset. Variables are lost, with one exception: those marked RTC_DATA_ATTR live in the RTC memory (8 KB) that stays powered. Use them for a boot counter, a cached channel or a few buffered readings.

A duty-cycled sensor node

This sketch wakes, measures first (before the radio draws power), publishes once and goes back to sleep. If the network is not available, it gives up and sleeps anyway.

#include <WiFi.h>
#include <PubSubClient.h>
#include <Preferences.h>
#include "esp_sleep.h"

const uint64_t SLEEP_US = 600ULL * 1000000ULL;    // 600 s in microseconds, 64-bit
const gpio_num_t WAKE_BTN = GPIO_NUM_33;          // an RTC GPIO, button to 3V3 with a 10k pull-down
const uint8_t TEMP_PIN = 34;

RTC_DATA_ATTR uint32_t bootCount = 0;             // survives deep sleep, lost on power cycle

float readTempC() {
  uint32_t sum = 0;
  for (int i = 0; i < 8; i++) sum += analogReadMilliVolts(TEMP_PIN);
  return (sum / 8.0f - 500.0f) / 10.0f;           // TMP36 conversion from lesson 3
}

bool connectWiFi(uint32_t timeoutMs) {
  Preferences prefs;
  prefs.begin("wifi", true);
  String ssid = prefs.getString("ssid", "");
  String pass = prefs.getString("pass", "");
  prefs.end();

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid.c_str(), pass.c_str());
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) delay(50);
  return WiFi.status() == WL_CONNECTED;
}

void goToSleep() {
  WiFi.disconnect(true);                          // switch the radio off cleanly
  WiFi.mode(WIFI_OFF);
  esp_sleep_enable_timer_wakeup(SLEEP_US);        // wake source 1: the timer
  esp_sleep_enable_ext0_wakeup(WAKE_BTN, 1);      // wake source 2: button pulls the pin HIGH
  Serial.flush();                                 // finish printing before power-down
  esp_deep_sleep_start();                         // does not return
}

void setup() {
  bootCount++;
  Serial.begin(115200);
  Serial.printf("Boot %u, wake cause %d\n", bootCount, (int)esp_sleep_get_wakeup_cause());

  float t = readTempC();                          // measure before the radio starts

  if (connectWiFi(8000)) {                        // give up after 8 s, never loop forever
    WiFiClient net;
    PubSubClient mqtt(net);
    mqtt.setServer("broker.local", 1883);
    if (mqtt.connect("node01")) {
      char msg[32];
      snprintf(msg, sizeof(msg), "%.1f,%u", t, bootCount);
      mqtt.publish("gwordal/node01/telemetry", msg);
      mqtt.disconnect();                          // flushes the packet and closes cleanly
    }
  }
  goToSleep();                                    // always sleep, even after a failure
}

void loop() {}                                    // never reached

The failure path matters for the budget. A failed 8 s attempt at 120 mA costs 120 x 8 = 960 mA-seconds, which is 2.7 times the normal 360 mA-seconds cycle. A node that keeps failing drains its battery much faster than one that succeeds.

Check yourself

A node is awake 2 s at 100 mA and sleeps 58 s at 0.010 mA, repeating every 60 s. What is the average current?

Check yourself

Which variable survives deep sleep?