ESP32 Deep Sleep for Battery Sensor Nodes: Timer, Button and MQTT
An ESP32 with Wi-Fi active draws on the order of a hundred milliamps or more, so a battery-powered node that stays connected all the time will drain an 18650 cell within days. Yet a soil moisture sensor or a weather station only needs to report every few minutes. The solution is deep sleep: the CPU, Wi-Fi and most of the chip power off, and only the RTC domain keeps a timer running to wake the chip later.
In this tutorial you will build a node that wakes every ten minutes or when a button is pressed, measures temperature and its own battery voltage, publishes over MQTT, and goes back to sleep, keeping a counter across sleeps in RTC memory. You will also learn to estimate battery life and why your development board may never reach the datasheet figures.
What Survives Deep Sleep
| Item | During deep sleep | After wake-up |
|---|---|---|
| CPU and normal RAM | Powered off | Lost; the sketch starts again from setup() |
| Wi-Fi and Bluetooth | Off | Must reconnect |
| RTC slow memory | Kept powered | Variables marked RTC_DATA_ATTR keep their values |
| RTC timer | Running | Can wake the chip after a set time |
| RTC GPIOs | Can be monitored | A level on one pin (ext0) or several (ext1) can wake the chip |
| Flash (NVS, Preferences) | Unpowered but non-volatile | Data kept, but frequent writes wear the flash |
Waking from deep sleep is effectively a reset. That changes how you write the sketch: all work happens in setup(), and loop() is never reached. Use esp_sleep_get_wakeup_cause() to find out why the chip woke up.
Circuit
Li-ion 3.7 V --+-- low-quiescent LDO (3.3 V) --> ESP32 3V3
|
100k
+------------> GPIO 34 (ADC1)
100k
|
GND
Push button: GPIO 33 --> GND, 10k pull-up from GPIO 33 to 3.3 V
DHT22 data: GPIO 4, powered from GPIO 25 so it can be switched offThe 100 kΩ divider halves the battery voltage so a full cell of about 4.2 V becomes 2.1 V, inside the ADC range. GPIO 34 is on ADC1; ADC2 pins cannot be read while Wi-Fi is active. Powering the DHT22 from a GPIO lets you switch it off during sleep. GPIO 33 is an RTC GPIO, which is required for ext0 wake-up.
The Sketch
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include "esp_sleep.h"
#define SLEEP_MINUTES 10
const uint64_t SLEEP_US = SLEEP_MINUTES * 60ULL * 1000000ULL;
const char* WIFI_SSID = "YourWiFi";
const char* WIFI_PASS = "YourWiFiPassword";
// A static IP skips DHCP and shortens the time Wi-Fi is on
IPAddress ip(192, 168, 1, 77), gw(192, 168, 1, 1), mask(255, 255, 255, 0);
const char* MQTT_HOST = "192.168.1.50";
const int SENSOR_PWR = 25, DHT_PIN = 4, BAT_PIN = 34;
const gpio_num_t WAKE_BTN = GPIO_NUM_33;
RTC_DATA_ATTR uint32_t bootCount = 0; // survives deep sleep
RTC_DATA_ATTR uint32_t failCount = 0;
DHT dht(DHT_PIN, DHT22);
WiFiClient net;
PubSubClient mqtt(net);
void goToSleep() {
mqtt.disconnect();
WiFi.disconnect(true); // turn the radio off
WiFi.mode(WIFI_OFF);
digitalWrite(SENSOR_PWR, LOW); // sensor off
esp_sleep_enable_timer_wakeup(SLEEP_US);
esp_sleep_enable_ext0_wakeup(WAKE_BTN, 0); // wake when button pulls LOW
Serial.println("Sleeping");
Serial.flush();
esp_deep_sleep_start(); // never returns
}
void setup() {
Serial.begin(115200);
bootCount++;
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
const char* why = (cause == ESP_SLEEP_WAKEUP_EXT0) ? "button" :
(cause == ESP_SLEEP_WAKEUP_TIMER) ? "timer" : "power-on";
// 1. Power the sensor first; DHT22 needs time after power-up
pinMode(SENSOR_PWR, OUTPUT);
digitalWrite(SENSOR_PWR, HIGH);
// 2. Start Wi-Fi in parallel with the sensor warm-up
WiFi.mode(WIFI_STA);
WiFi.config(ip, gw, mask, gw);
WiFi.begin(WIFI_SSID, WIFI_PASS);
// 3. Battery voltage: divider halves it, so multiply by 2
analogSetPinAttenuation(BAT_PIN, ADC_11db);
float vbat = analogReadMilliVolts(BAT_PIN) * 2 / 1000.0;
delay(2000); // DHT22 warm-up
dht.begin();
float t = dht.readTemperature();
// 4. Wait for Wi-Fi, but never longer than 10 s
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) delay(50);
if (WiFi.status() != WL_CONNECTED) { failCount++; goToSleep(); }
// 5. Publish one message
mqtt.setServer(MQTT_HOST, 1883);
if (mqtt.connect("esp32-batnode", "iotuser", "secret")) {
char msg[128];
snprintf(msg, sizeof(msg),
"{\"temp_c\":%.1f,\"vbat\":%.2f,\"boot\":%lu,\"fails\":%lu,\"wake\":\"%s\",\"ms\":%lu}",
t, vbat, (unsigned long)bootCount, (unsigned long)failCount, why, millis());
mqtt.publish("garden/batnode/telemetry", msg);
mqtt.loop();
delay(100); // let the TCP stack send it
} else {
failCount++;
}
goToSleep();
}
void loop() {
// never reached
}
The message includes ms, the time the node was awake before publishing. Watching this value is the easiest way to optimise: every second saved is a second of high current avoided.
Estimating Battery Life
Average current is the time-weighted mix of the awake and asleep currents. Measure both with a multimeter in series with the battery (or a USB power meter for a rough idea), then apply the formula.
I_avg = (I_awake x t_awake + I_sleep x t_sleep) / (t_awake + t_sleep) Life (hours) = Battery capacity (mAh) / I_avg (mA) Example with assumed values (measure your own!): I_awake = 120 mA for t_awake = 3 s I_sleep = 0.05 mA for t_sleep = 597 s (10-minute cycle) I_avg = (120 x 3 + 0.05 x 597) / 600 = (360 + 29.85) / 600 = 0.65 mA Life = 2000 mAh / 0.65 mA = about 3077 h = about 128 days
The example shows that the awake period dominates: 360 of the 390 mA-seconds per cycle are spent awake. Halving the awake time almost doubles the battery life, while halving the sleep current barely matters. Real life will be shorter because of battery self-discharge, the cut-off voltage of the regulator, and Wi-Fi retries when the signal is weak.
Why Your Board Draws Too Much in Sleep
- Development boards include a USB-to-UART chip, a power LED and often an AMS1117 regulator, whose own quiescent current is in the milliamp range. The ESP32 may be asleep while the board still draws milliamps. For long battery life use a bare module with a low-quiescent LDO, or a board designed for low power.
- Sensors left powered: switch them from a GPIO or a MOSFET, as with the DHT22 here.
- Floating pins: a button wake pin without a pull-up can trigger random wake-ups.
- Using GPIO 0, 2 or 12 for the circuit: these are strapping pins that affect boot; keep sensors off them where possible.
- Writing to flash every wake: use
RTC_DATA_ATTRvariables for counters instead of Preferences. - Serial Monitor shows garbage after wake-up: that is boot ROM output at a different baud rate; it is normal.
Summary
A deep-sleeping ESP32 runs setup() from scratch on every wake, does its work quickly and calls esp_deep_sleep_start(). RTC memory keeps counters, the timer and ext0 sources wake it, a static IP and short timeouts reduce awake time, and a voltage divider on an ADC1 pin reports battery health. Measure your awake and sleep currents and use the formula to predict how long the node will last.