A sensor that must last a year on a battery works for under a second per hour. The rest of the time it sleeps. In this lab we measure what each state of an ESP32-C6 consumes, build the energy budget of a real node, and discover that the biggest saving does not come from sleeping deeper, but from waking up for less time.
1Objectives of the lab
- Identifying the consumption states of a modern microcontroller and what stays on in each of them
- Measuring the current in each state, from hundreds of milliamps down to a few microamps
- Programming deep sleep and its wakeup sources: timer and input pin
- Preserving information across reboots using memory in the always-powered domain
- Calculating the average current, the energy budget and the battery autonomy
- Reducing the duration of the active phase - by far the most effective optimization
- Understanding the difference between the chip's consumption and the development board's consumption
2About the project
We build an autonomous sensor node: it wakes up at a fixed interval, reads the temperature and humidity, sends the values over Wi-Fi and goes back to sleep. Then we optimize it step by step and watch the calculated autonomy grow from a few days to over a year - without changing the battery and without changing the chip.
3Materials needed
- 1 ESP32-C6-DevKitC-1
- 1 USB-C data cable
- 1 INA219 module
- 1 Multimeter with a µA range
- 1 16×2 LCD with I²C adapter (PCF8574)
- 1 I²C level shifter 3.3 V ↔ 5 V
- 1 5 V bench supply
- 1 BME280 sensor (I²C) or DHT22
- 1 Push button + 10 kΩ resistor
- 1 Breadboard
- 20 Jumper wires
- 1 2×AA holder or LiPo battery (optional)
Wiring diagram
2. Set the supply to 5 V and limit the current to 1 A before connecting it. The 5V pin goes through the board's regulator, so the INA219 sees the board's consumption as it is, not just the chip's.
3. Do not connect the LCD directly to GPIO6/GPIO7. The PCF8574 adapter has pull-up resistors to 5 V, and the ESP32-C6 pins are not 5 V tolerant. Use the level shifter (LV side to the board's 3V3, HV side to 5 V) or remove the two pull-up resistors from the adapter.
4. Ground is common: the supply's minus and the GND of the board, the INA219, the shifter and the LCD.
For the sleep states, the multimeter on its µA range goes in series on the same red wire, between the supply and the INA219.
4Consumption states
A modern microcontroller does not have two states (on / off), but a whole ladder of them. Each step turns off something extra and, in exchange, needs more time to get back to work.
| State | What stays on | Order of magnitude | Wakeup |
|---|---|---|---|
| Active, radio on | processor, memory, Wi-Fi transmitting | tens-hundreds of mA | - |
| Modem-sleep | processor and memory; the radio turns off between frames | a few tens of mA | immediate |
| Light-sleep | memory keeps its contents, clocks stop | hundreds of µA | microseconds, the program continues where it left off |
| Deep-sleep | only the always-powered domain: the timer and a small memory | a few µA | milliseconds, but the program starts over |
| Hibernation | practically nothing besides the wakeup circuit | under 1 µA | like a full power-on |
setup() runs again, and all ordinary variables are lost. In exchange, the consumption
is about a hundred times lower. Choosing between them is the first design trade-off of any
battery-powered node.The development-board trap
- the USB-to-serial converter - stays powered and draws current permanently
- the voltage regulator - has its own idle current, sometimes on the order of milliamps
- the power LED - 2-5 mA, i.e. by itself a thousand times the sleeping chip
- the pull-up/pull-down resistors on the programming lines
5The energy budget calculator
Enter the measured currents and the desired period. The calculator shows the average current, the split of the charge between the two phases, and the estimated autonomy.
- Ten-times-better sleep: lower the sleep current from 15 µA to 1.5 µA. Look at the autonomy.
- Four-times-shorter wakeup: put the sleep current back to 15 µA and lower the active duration from 4 s to 1 s.
6Setting up the work environment
- Install ESP32 support in the Arduino IDE
In
File → Preferences → Additional Boards Manager URLsadd:addresshttps://espressif.github.io/arduino-esp32/package_esp32_index.json
Then
Tools → Board → Boards Manager, search for "esp32" and install the package esp32 by Espressif Systems, version 3.0 or newer. The 2.x versions do not know about the ESP32-C6. - Select the board and the port
Tools → Board → ESP32 Arduino → ESP32C6 Dev Module. ForPort, pick the port that appears after connecting the board.If no port appearsThe USB-C cable is, in many cases, charge-only. Try another cable before suspecting the drivers. The C6 board has two USB connectors - use the one markedUSB(notUART) for the native serial port. - Verify the toolchain with a minimal programtest.ino
void setup() { Serial.begin(115200); delay(1000); // let the native serial port initialize Serial.println("ESP32-C6 working"); } void loop() { Serial.printf("time: %lu ms\n", millis()); delay(1000); }Upload it and open the serial monitor at 115200 baud. If you see the messages, the environment is ready.
- Install the sensor library
Tools → Manage Libraries, search for Adafruit BME280 and install it along with the suggested dependencies (Adafruit Unified Sensor).
7Deep sleep and the memory that survives it
The first program demonstrates the behavior that surprises everyone: after deep sleep, the microcontroller does not continue, it reboots. The only thing that survives is what we put in the memory of the always-powered domain.
#include <esp_sleep.h>
#define uS_PER_S 1000000ULL
#define SLEEP_S 10 // how many seconds we sleep
// The RTC_DATA_ATTR attribute places the variable in the always-powered
// domain's memory. It survives deep sleep; an ordinary variable does not.
RTC_DATA_ATTR int wakeup_count = 0;
RTC_DATA_ATTR uint32_t total_active_time_ms = 0;
int ordinary_counter = 0; // this will always be 0 after wakeup
void print_wakeup_reason() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_TIMER: Serial.println("woken by the timer"); break;
case ESP_SLEEP_WAKEUP_GPIO: Serial.println("woken by a pin"); break;
case ESP_SLEEP_WAKEUP_EXT1: Serial.println("woken by EXT1"); break;
default: Serial.println("normal power-on (not from sleep)");
}
}
void setup() {
uint32_t t0 = millis();
Serial.begin(115200);
delay(200); // without this pause we miss the first messages
wakeup_count++;
ordinary_counter++;
Serial.println("-----------------------------");
print_wakeup_reason();
Serial.printf("wakeups (RTC memory) : %d\n", wakeup_count);
Serial.printf("ordinary counter : %d <- always 1\n", ordinary_counter);
Serial.printf("cumulative active time : %lu ms\n", total_active_time_ms);
// The actual work would go here: sensor reading, transmission.
delay(500);
total_active_time_ms += millis() - t0;
Serial.printf("sleeping for %d seconds...\n", SLEEP_S);
Serial.flush(); // flush the serial buffer BEFORE sleeping
esp_sleep_enable_timer_wakeup(SLEEP_S * uS_PER_S);
esp_deep_sleep_start(); // nothing executes past this point
}
void loop() {
// Never reached: esp_deep_sleep_start() does not return.
}wakeup_countgrows every cycle, butordinary_counteralways stays 1 - ordinary memory is lost.- The statements after
esp_deep_sleep_start()never execute; the function does not return. - Without
Serial.flush()the last characters are lost: the chip falls asleep before they get a chance to go out over the wire. It is a mistake that produces truncated messages and a lot of confusion.
8Wakeup sources
The timer is not the only way out of sleep. A real node must also react to external events: a button, a motion sensor, a door contact.
#include <esp_sleep.h>
#define uS_PER_S 1000000ULL
#define SLEEP_S 30
#define BUTTON_PIN GPIO_NUM_4 // on the C6, pin wakeup requires GPIO0..GPIO7
RTC_DATA_ATTR int timer_wakeups = 0;
RTC_DATA_ATTR int button_wakeups = 0;
void setup() {
Serial.begin(115200);
delay(200);
esp_sleep_wakeup_cause_t reason = esp_sleep_get_wakeup_cause();
if (reason == ESP_SLEEP_WAKEUP_TIMER) {
timer_wakeups++;
Serial.println("timer - scheduled measurement");
} else if (reason == ESP_SLEEP_WAKEUP_GPIO) {
button_wakeups++;
Serial.println("BUTTON - unexpected event!");
} else {
Serial.println("cold start");
}
Serial.printf("timer: %d button: %d\n",
timer_wakeups, button_wakeups);
// The button ties the pin to ground when pressed, so we hold it high with
// an internal resistor and wake up on the falling edge.
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Two wakeup sources active at the same time.
esp_sleep_enable_timer_wakeup(SLEEP_S * uS_PER_S);
esp_deep_sleep_enable_gpio_wakeup(BIT(BUTTON_PIN), ESP_GPIO_WAKEUP_GPIO_LOW);
Serial.println("sleeping - press the button or wait 30 s");
Serial.flush();
esp_deep_sleep_start();
}
void loop() {}| Chip | Pin wakeup function | Allowed pins |
|---|---|---|
| Classic ESP32 | esp_sleep_enable_ext0_wakeup() / ext1 | the RTC pins |
| ESP32-C3 / C6 / H2 | esp_deep_sleep_enable_gpio_wakeup() | GPIO0-GPIO7 |
| ESP32-S3 | both variants | the RTC pins |
Waking up from light-sleep
When you need the program to continue where it left off - for example to keep a connection open - use light sleep. The difference in code is minimal, but the behavior is completely different:
esp_sleep_enable_timer_wakeup(5 * uS_PER_S);
esp_light_sleep_start(); // this function DOES RETURN
Serial.println("woke up and continuing from here");
Serial.printf("variables are intact: %d\n", ordinary_counter);9Shortening the active phase
The calculator in section 5 showed us where the energy goes. Now we attack the active phase. An unoptimized node spends most of its awake time waiting to connect to Wi-Fi, not reading the sensor.
What a connection takes
| Stage | Typical duration | Can it be eliminated? |
|---|---|---|
| Scanning all channels | 1-3 s | yes - if we remember the channel and the access point's address |
| Association and authentication | 100-300 ms | partially |
| Getting an address via DHCP | 0.5-2 s | yes - with a fixed IP address |
| The actual transmission | 10-50 ms | no, but it is negligible anyway |
#include <WiFi.h>
#include <esp_wifi.h>
const char* SSID = "lab_network";
const char* PASSWORD = "password";
// Everything worth remembering from the previous cycle, so we do not redo it.
RTC_DATA_ATTR uint8_t saved_channel = 0;
RTC_DATA_ATTR uint8_t saved_bssid[6];
RTC_DATA_ATTR bool have_saved_data = false;
RTC_DATA_ATTR uint32_t consecutive_failures = 0;
// A fixed address completely removes the DHCP negotiation.
IPAddress local_ip(192, 168, 1, 77);
IPAddress gateway (192, 168, 1, 1);
IPAddress subnet (255, 255, 255, 0);
IPAddress dns (192, 168, 1, 1);
bool connect(uint32_t limit_ms = 8000) {
uint32_t t0 = millis();
WiFi.mode(WIFI_STA);
WiFi.config(local_ip, gateway, subnet, dns); // no DHCP
WiFi.setSleep(true); // the radio turns off between frames
if (have_saved_data) {
// We know exactly which channel and access point: no scanning.
WiFi.begin(SSID, PASSWORD, saved_channel, saved_bssid);
} else {
WiFi.begin(SSID, PASSWORD); // the first time, we search
}
while (WiFi.status() != WL_CONNECTED && millis() - t0 < limit_ms) {
delay(10);
}
if (WiFi.status() != WL_CONNECTED) {
consecutive_failures++;
// After three failures we suspect the access point moved to another
// channel and drop the saved data, to redo a full scan.
if (consecutive_failures >= 3) have_saved_data = false;
return false;
}
saved_channel = WiFi.channel();
memcpy(saved_bssid, WiFi.BSSID(), 6);
have_saved_data = true;
consecutive_failures = 0;
Serial.printf("connected in %lu ms (channel %d, IP %s)\n",
millis() - t0, saved_channel, WiFi.localIP().toString().c_str());
return true;
}Measure the gain
Run the connection ten times with have_saved_data = false forced, then ten times
normally. Record the average time in each case and recalculate the autonomy with the widget from
section 5. The difference is usually a factor of three to five.
10The complete node
We put everything together: wake up, read the sensor, transmit quickly, go to sleep. The program measures the duration of its own active phase and reports it, so we can track the effect of the optimizations.
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <esp_sleep.h>
#define uS_PER_S 1000000ULL
#define PERIOD_S 900 // 15 minutes
#define SDA_PIN 6
#define SCL_PIN 7
const char* SSID = "lab_network";
const char* PASSWORD = "password";
const char* SERVER = "http://192.168.1.10:8000/readings";
RTC_DATA_ATTR uint32_t cycle = 0;
RTC_DATA_ATTR uint32_t total_active_time_ms = 0;
RTC_DATA_ATTR uint8_t saved_channel = 0;
RTC_DATA_ATTR uint8_t saved_bssid[6];
RTC_DATA_ATTR bool have_saved_data = false;
Adafruit_BME280 bme;
void go_to_sleep(const char* reason) {
total_active_time_ms += millis();
Serial.printf("[%s] active %lu ms (cumulative %lu ms over %lu cycles)\n",
reason, millis(), total_active_time_ms, cycle);
Serial.flush();
WiFi.disconnect(true); // explicitly turn off the radio
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t) PERIOD_S * uS_PER_S);
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
delay(150);
cycle++;
// --- 1. the sensor, as early as possible: it can be read without Wi-Fi on ---
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) { // some modules respond at 0x77
Serial.println("the sensor is not responding");
go_to_sleep("no sensor");
}
// Forced mode: the sensor takes a single measurement on request and then stops.
// This is exactly what we need - a sensor that measures continuously wastes energy.
bme.setSampling(Adafruit_BME280::MODE_FORCED,
Adafruit_BME280::SAMPLING_X1, // temperature
Adafruit_BME280::SAMPLING_X1, // pressure
Adafruit_BME280::SAMPLING_X1, // humidity
Adafruit_BME280::FILTER_OFF);
bme.takeForcedMeasurement();
float temperature = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F;
Serial.printf("cycle %lu: %.2f degC %.1f%% %.1f hPa\n",
cycle, temperature, humidity, pressure);
// --- 2. the network, as late and as briefly as possible ---
WiFi.mode(WIFI_STA);
WiFi.setSleep(true);
if (have_saved_data) WiFi.begin(SSID, PASSWORD, saved_channel, saved_bssid);
else WiFi.begin(SSID, PASSWORD);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 8000) delay(10);
if (WiFi.status() != WL_CONNECTED) {
have_saved_data = false; // redo a scan next time
go_to_sleep("no network");
}
saved_channel = WiFi.channel();
memcpy(saved_bssid, WiFi.BSSID(), 6);
have_saved_data = true;
// --- 3. the transmission ---
char body[160];
snprintf(body, sizeof(body),
"{\"cycle\":%lu,\"temp\":%.2f,\"humid\":%.1f,\"pres\":%.1f,\"active_ms\":%lu}",
cycle, temperature, humidity, pressure, millis());
HTTPClient http;
http.begin(SERVER);
http.addHeader("Content-Type", "application/json");
int code = http.POST(body);
Serial.printf("server: %d\n", code);
http.end();
go_to_sleep("ok");
}
void loop() {}11Measurement protocol
Fill in this table with your own values. The right-hand column is the one that matters for the report.
| Measured state | How to trigger it | Measured current |
|---|---|---|
| Active, Wi-Fi connected and transmitting | during the http.POST() call | ………… mA |
| Active, Wi-Fi on but idle | after connecting, in a delay() loop | ………… mA |
| Active, Wi-Fi off | WiFi.mode(WIFI_OFF) then an empty loop | ………… mA |
| Light-sleep | esp_light_sleep_start() with a 30 s wakeup | ………… µA |
| Deep-sleep | esp_deep_sleep_start() | ………… µA |
| Deep-sleep, power LED desoldered | optional, only with the instructor's approval | ………… µA |
Reading on the LCD
The program below steps through the active states on its own, holds each state for 10 seconds and shows on the LCD the average current measured by the INA219. The board needs no computer: after uploading the program, unplug the USB cable and switch the supply on. Required libraries, from the Library Manager: Adafruit INA219 and LiquidCrystal I2C.
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Wire.h>
#include <Adafruit_INA219.h>
#include <LiquidCrystal_I2C.h>
#include <esp_sleep.h>
#define SDA_PIN 6
#define SCL_PIN 7
#define DURATION_MS 10000UL // how long each state is held
#define SLEEP_S 20 // how long each sleep state lasts
#define uS_PER_S 1000000ULL
const char* SSID = "lab_network";
const char* PASSWORD = "password";
Adafruit_INA219 ina(0x40);
LiquidCrystal_I2C lcd(0x27, 16, 2); // some adapters answer at 0x3F
WiFiUDP udp;
// Continuous transmission: UDP broadcast packets, no server needed.
void transmit() {
static uint8_t buf[1000];
udp.beginPacket(IPAddress(255, 255, 255, 255), 9999);
udp.write(buf, sizeof(buf));
udp.endPacket();
}
// Holds the state for DURATION_MS, averages the current, shows it every second.
float measure(const char* name, void (*work)() = nullptr) {
lcd.clear();
lcd.print(name);
double sum = 0;
uint32_t n = 0;
unsigned long t0 = millis(), shown = t0;
while (millis() - t0 < DURATION_MS) {
if (work) work(); else delay(2);
sum += ina.getCurrent_mA();
n++;
if (millis() - shown >= 1000) {
shown = millis();
lcd.setCursor(0, 1);
lcd.printf("I=%7.1f mA ", sum / n);
}
}
return sum / n;
}
// The LCD is powered before the shunt: the text stays while the board sleeps.
void announceSleep(const char* name) {
lcd.clear();
lcd.print(name);
lcd.setCursor(0, 1);
lcd.print("read multimeter");
ina.powerSave(true); // INA219 drops to a few uA
delay(50);
}
void setup() {
Wire.begin(SDA_PIN, SCL_PIN);
lcd.init();
lcd.backlight();
if (!ina.begin(&Wire)) {
lcd.print("no INA219!");
while (true) delay(1000);
}
ina.setCalibration_32V_1A(); // 0.04 mA step, handles the Wi-Fi peaks
WiFi.mode(WIFI_OFF);
measure("WiFi off");
lcd.clear();
lcd.print("connecting...");
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(100);
measure("WiFi idle");
measure("WiFi transmit", transmit);
WiFi.mode(WIFI_OFF);
announceSleep("Light-sleep 20 s");
esp_sleep_enable_timer_wakeup(SLEEP_S * uS_PER_S);
esp_light_sleep_start(); // the program continues from here
ina.powerSave(false);
announceSleep("Deep-sleep 20 s");
esp_deep_sleep_start(); // on wakeup, setup() starts over
}
void loop() {}How to get a correct measurement
- Power the board from a battery or a separate source, not through USB from the computer
- Disconnect the programming cable during the measurement - the USB converter adds its own consumption
- Wait at least 5 seconds after entering sleep before reading: the first interval contains transients
- For the active phase use the average value over the whole duration, not the peak
- Repeat every measurement three times and note the spread
12Assignments
- Upload
sleep_01_basic.inoand let it run for ten cycles. Explain in the report whywakeup_countincreases whileordinary_counterdoes not. - Fill in the whole measurement table from section 11.
- Using the widget from section 5, calculate the node's autonomy on two AA batteries (about 2600 mAh) for periods of 1, 15 and 60 minutes. Produce an autonomy-versus-period chart.
- Measure the duration of the active phase before and after the optimizations from section 9 (remembered channel and fixed IP address). Recalculate the autonomy in both cases and express the gain as a percentage.
- Add button wakeup to the complete node, so that pressing it triggers an immediate measurement, outside the regular schedule. Verify, through the counter in RTC memory, that both sources work.
- Compare light-sleep with deep-sleep for a 5-second period. At what period does deep-sleep become more advantageous, taking into account that a full reboot also takes time and consumes energy?
13Deeper-dive challenge
- keep the last transmitted value in RTC memory;
- if the new value differs by less than 0.3 °C, do not transmit at all and double the sleep interval (up to a maximum of one hour);
- if it differs by more than 1 °C, transmit and lower the interval to the minimum (one minute);
- still transmit at least once an hour regardless, so the server knows the node is alive.