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

ItemDuring deep sleepAfter wake-up
CPU and normal RAMPowered offLost; the sketch starts again from setup()
Wi-Fi and BluetoothOffMust reconnect
RTC slow memoryKept poweredVariables marked RTC_DATA_ATTR keep their values
RTC timerRunningCan wake the chip after a set time
RTC GPIOsCan be monitoredA level on one pin (ext0) or several (ext1) can wake the chip
Flash (NVS, Preferences)Unpowered but non-volatileData 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 off

The 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

Wake, measure, publish over MQTT with a static IP, then deep sleep
#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_ATTR variables 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.