>_ ctrlaltbrian
Tutorials ESP32 Arduino Raspberry Pi Pico About Queue

ctrlaltbrian

IoT with ESP32: from sensor to dashboard

The complete path from a wired-up sensor to a web dashboard you can check from your phone.

104 chapters · ~55 hours · last updated 2026-09-24

$29

Chapter 01

ESP32: read a DHT22 temperature and humidity sensor

esp32 · 20 min

The DHT22 is the sensor I reach for when I want temperature and humidity without thinking too hard. It is cheap, it is accurate enough, and the wiring is three wires. This tutorial gets you from a fresh ESP32 to reading values in the Serial Monitor in about 20 minutes.

What you need

  • ESP32 dev board (any variant with a USB port and the usual GPIO header)
  • DHT22 sensor (the blue one, not the DHT11)
  • 10k ohm resistor (only if your DHT22 breakout does not have one built in)
  • Three jumper wires
  • USB cable

If your DHT22 is on a small breakout board (e.g. the ones with three pins and a tiny PCB), the pull-up resistor is usually already on the board. If it is the bare sensor with four pins, you need to add a 10k between the data pin and VCC. The wiring section assumes the breakout.

Wiring

The DHT22 has three pins: VCC, data, and GND. On most breakouts they are labeled.

DHT22 pin ESP32 pin
VCC 3.3V
DATA GPIO 4
GND GND

Some tutorials use GPIO 15 or GPIO 2. Those work too. GPIO 4 is fine and avoids the boot-strapping pins (e.g. GPIO 0, GPIO 2, GPIO 15) that do weird things during reset.

Install the Arduino IDE

If you do not have it yet: https://www.arduino.cc/en/software. The regular IDE, not the Web Editor. You will want it offline.

Then add the ESP32 board package. In the IDE:

  1. File >> Preferences >> Additional boards manager URLs
  2. Paste: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  3. Tools >> Board >> Boards Manager >> search esp32 >> install

Restart the IDE after this step. It always needs a restart.

Install the DHT library

Sketch >> Include Library >> Manage Libraries >> search DHT sensor library by Adafruit. Install it. It will ask if you also want to install the Adafruit Unified Sensor dependency. Say yes.

The code

ESP32 (Arduino)

#include "DHT.h"

#define DHT_PIN 4
#define DHT_TYPE DHT22

DHT dht(DHT_PIN, DHT_TYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
  Serial.println("DHT22 reading on ESP32");
}

void loop() {
  float humidity = dht.readHumidity();
  float temperature = dht.readTemperature();

  if (isnan(humidity) || isnan(temperature)) {
    Serial.println("Failed to read from DHT22. Check wiring.");
  } else {
    Serial.print("Humidity: ");
    Serial.print(humidity);
    Serial.print("%  Temperature: ");
    Serial.print(temperature);
    Serial.println("C");
  }
  delay(2000);
}

Arduino (Uno, Nano, Mega)

#include "DHT.h"

#define DHT_PIN 2   // any digital pin; pin 2 is convenient on Uno
#define DHT_TYPE DHT22

DHT dht(DHT_PIN, DHT_TYPE);

void setup() {
  Serial.begin(9600);   // 9600 is more reliable than 115200 on the Uno's USB bridge
  dht.begin();
  Serial.println("DHT22 reading on Arduino");
}

void loop() {
  float humidity = dht.readHumidity();
  float temperature = dht.readTemperature();

  if (isnan(humidity) || isnan(temperature)) {
    Serial.println("Failed to read from DHT22");
  } else {
    Serial.print("Humidity: ");
    Serial.print(humidity);
    Serial.print("%  Temperature: ");
    Serial.print(temperature);
    Serial.println("C");
  }
  delay(2000);
}

MicroPython (ESP32 or Pico)

from machine import Pin
import dht
import time

sensor = dht.DHT22(Pin(4))

while True:
    try:
        sensor.measure()
        t = sensor.temperature()
        h = sensor.humidity()
        print(f'Temp: {t} C  Humidity: {h} %')
    except OSError:
        print('Failed to read DHT22')
    time.sleep(2)

Raspberry Pi Python

pip3 install Adafruit-DHT
import Adafruit_DHT
import time

DHT_SENSOR = Adafruit_DHT.DHT22
DHT_PIN = 4   # BCM pin 4 (physical pin 7)

while True:
    humidity, temperature = Adafruit_DHT.read(DHT_SENSOR, DHT_PIN)
    if humidity is not None:
        print(f'Temp: {temperature:.1f} C  Humidity: {humidity:.1f} %')
    else:
        print('Failed to read DHT22')
    time.sleep(2)

The Adafruit library requires sudo on some Pi images, because the GPIO timing driver needs root. If you get a "no permissions" error, run with sudo python3 your_script.py.

What you should see

Upload it: Sketch >> Upload. Then Tools >> Serial Monitor (or Ctrl+Shift+M). Set the baud rate to 115200 (top right dropdown). The common mistake is leaving it at 9600 and seeing nothing. If you see nothing, check that dropdown first.

What you should see

DHT22 reading on ESP32
Humidity: 42.30%  Temperature: 23.10C
Humidity: 42.40%  Temperature: 23.10C
Humidity: 42.40%  Temperature: 23.20C

If you see Failed to read from DHT22 over and over, the usual suspects are:

  • Wiring is on the wrong GPIO. Double-check the pin number, not the silkscreen.
  • The pull-up resistor is missing (bare sensor, no breakout).
  • The data pin is one of the boot-strapping pins (e.g. GPIO 0, GPIO 2).
  • The Serial Monitor baud rate is wrong.

Where to go from here

This is the foundation. Once you have the reading, the next steps are usually one of:

  • Push it to MQTT or a Google Sheet.
  • Show it on a small OLED display.
  • Log it to an SD card and graph it later.

The MQTT version is in the book IoT with ESP32 (linked from the books page). The OLED version is one of the next tutorials on this site.

What to build next

  • The BME280 tutorial is the I2C upgrade when accuracy matters.
  • The OLED tutorial displays the readings.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 02

ESP32: serve a sensor dashboard from the chip itself

esp32 · 45 min

Here is the project I send people when they ask "what is the point of an ESP32, really." You read a sensor, the ESP32 serves a small HTML page on your local Wi-Fi, and any phone on the same network can see the live readings.

No cloud account. No MQTT broker. No app. Just one chip and one URL.

What you need

  • ESP32 dev board
  • Any sensor that works over I2C or SPI (this tutorial uses the BME280, but a BMP280 or even a DHT22 on a separate GPIO works too)
  • USB cable

Install the libraries

In the Arduino IDE:

  • Sketch >> Include Library >> Manage Libraries >> install WebServer (built in, no install needed) and Adafruit BME280.

For BME280 specifically, also install Adafruit Unified Sensor.

The code

#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

WebServer server(80);
Adafruit_BME280 bme;

float temperature = 0;
float humidity    = 0;
float pressure    = 0;

void handleRoot() {
  String html = "<!DOCTYPE html><html><head>";
  html += "<meta charset='utf-8'>";
  html += "<meta http-equiv='refresh' content='5'>";
  html += "<title>ESP32 sensor dashboard</title>";
  html += "<style>body{font-family:sans-serif;background:#0f1115;color:#e6e9ef;"
          "display:flex;flex-direction:column;align-items:center;padding:2rem;}"
          ".card{background:#161a22;padding:1.5rem 2rem;margin:0.5rem;border-radius:6px;"
          "min-width:220px;text-align:center;}"
          ".value{font-size:2.5rem;color:#4ea1ff;}</style></head><body>";
  html += "<h1>ESP32 sensor dashboard</h1>";
  html += "<div class='card'><div class='value'>" + String(temperature, 1) + " &deg;C</div>"
          "<div>temperature</div></div>";
  html += "<div class='card'><div class='value'>" + String(humidity, 1) + " %</div>"
          "<div>humidity</div></div>";
  html += "<div class='card'><div class='value'>" + String(pressure, 0) + " hPa</div>"
          "<div>pressure</div></div>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void setup() {
  Serial.begin(115200);
  Wire.begin();

  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280. Check I2C address (0x76 vs 0x77).");
    while (1);
  }

  WiFi.begin(ssid, password);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.begin();
}

void loop() {
  server.handleClient();
  temperature = bme.readTemperature();
  humidity    = bme.readHumidity();
  pressure    = bme.readPressure() / 100.0;
}

Upload it, open Serial Monitor, and look for the IP address. It looks like 192.168.1.42. Open that in any browser on the same network.

The HTML uses <meta http-equiv='refresh' content='5'> to reload the page every 5 seconds. That works on any device, no JavaScript needed. If you want smoother updates, the book version uses WebSockets.

Why the I2C address might be 0x77 instead of 0x76

Some BME280 boards have SDO tied to VCC instead of GND, which changes the address. If bme.begin(0x76) fails, try bme.begin(0x77). If both fail, run an I2C scanner sketch and find the actual address.

Things that will trip you up

  • Wrong Wi-Fi credentials. The Serial Monitor will print Connecting... forever. Edit ssid and password.
  • 5GHz Wi-Fi. The ESP32 only does 2.4GHz. If your router has both bands and they have the same SSID, force the device onto the 2.4GHz band in the router settings.
  • Captive portals. Coffee shop Wi-Fi and most guest networks block direct connections. Test this at home first.
  • The BME280 is not on the I2C pins you think. On most ESP32 dev boards, SDA is GPIO 21 and SCL is GPIO 22. That is the default for Wire.begin(), which is what this sketch uses.

What you just built

A self-contained sensor dashboard that:

  • Boots in about 3 seconds.
  • Survives a power cycle without any setup.
  • Works on any phone, laptop, or tablet on the same network.
  • Sends zero data to any third party.

This is the foundation for a lot of home automation projects. The next steps are usually adding more sensors, logging to MQTT, or replacing the HTML refresh with WebSockets for smoother updates. All of those are in the books.

What to build next

  • The LittleFS web server tutorial replaces string-built HTML.
  • The MQTT tutorial moves the data to a broker.
  • The book IoT with ESP32 bundles the connectivity tutorials.

Chapter 03

ESP32: deep sleep and battery life, the honest numbers

esp32 · 40 min

Deep sleep is the difference between a project that lasts a weekend on a USB power bank and one that lasts a year on a couple of AAs. This is the tutorial I wish someone had written for me the first time I tried to build a battery-powered sensor.

The short version: an ESP32 in deep sleep pulls about 10 uA when wired right. Wired wrong, it pulls 30 mA and your battery is dead in two days. This tutorial is about getting to the 10 uA.

What you need

  • ESP32 dev board (the bare module, not a board with a USB-serial chip you do not need powered; more on this below)
  • Multimeter with a uA range, or a USB power meter that can read down to 0.01A
  • A battery (single 18650, two AAs in series, or a LiPo)
  • Jumper wires

The two kinds of ESP32 boards

This matters.

  1. Plain ESP32 dev boards (e.g. ESP32-DevKitC, NodeMCU-32S, most of what you find on Amazon). These have a USB-serial chip (CP2102 or CH340) and a voltage regulator that draws about 10-20 mA even when the ESP32 is asleep.
  2. Bare ESP32 modules (the WROOM-32 chip on its own, or a board that explicitly says "low power"). These can get to deep sleep currents of about 10 uA.

If you are serious about battery life, you need option 2, or you need to modify option 1 (cut the trace to the regulator LED, etc., which I do not recommend for beginners).

The code

#define uS_TO_S_FACTOR 1000000ULL
#define TIME_TO_SLEEP  60          // seconds between wake-ups

RTC_DATA_ATTR int bootCount = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);   // give the serial time to attach

  bootCount++;
  Serial.println("Boot number: " + String(bootCount));

  // ---- do your work here ----
  // (read a sensor, send to MQTT, whatever)
  // ----------------------------

  Serial.println("Going to sleep for " + String(TIME_TO_SLEEP) + " seconds");
  esp_sleep_enable_timer_wakeup(TIME_TO_SLEEP * uS_TO_S_FACTOR);
  esp_deep_sleep_start();
}

void loop() {
  // never reached
}

Upload it. Open Serial Monitor. You will see one boot message, then silence for 60 seconds, then another boot message. That is the chip waking up, running setup(), and going back to sleep.

Measuring the actual current

This is the part most tutorials skip. Put your multimeter in series with the battery (between the battery positive and the board's VIN or BAT pin). Use the uA or mA range.

What you should see:

  • During the boot (about 1-3 seconds): 80-260 mA, spiky.
  • During sleep: the steady-state current when nothing is happening.

For a plain dev board, "during sleep" is going to be 10-20 mA. That is the USB-serial chip and the regulator. For a bare module, it should be under 50 uA.

If you see steady 30 mA, the most common cause is the on-board LED. Find the LED on your board, find the GPIO it is on (usually GPIO 2), and turn it off in setup():

pinMode(2, OUTPUT);
digitalWrite(2, LOW);

Battery math

A single 18650 is about 2500 mAh. If your sleep current is 10 uA and your active current is 100 mA for 3 seconds every 60 seconds:

  • Sleep: 10 uA average (because you sleep 99.95% of the time)
  • Active: 100 mA * 3s / 60s = 5 mA average
  • Total: about 5 mA average
  • Battery life: 2500 / 5 = 500 hours = 20 days

That is with a plain dev board. With a bare module at 10 uA sleep, you are talking about 6 months on the same battery.

What wakes the ESP32 up

  • esp_sleep_enable_timer_wakeup(seconds): after N seconds
  • esp_sleep_enable_ext0_wakeup(gpio, level): when a GPIO goes high or low (e.g. a button press)
  • esp_sleep_enable_touchpad_wakeup(): when a touch pin is touched

You can combine wakeup sources (timer OR button). The ESP32 wakes on the first one that fires.

What eats battery when nothing is happening

In rough order of impact:

  1. The USB-serial chip on dev boards (~5-10 mA)
  2. The voltage regulator (~5-10 mA when fed 5V; less when fed battery direct)
  3. The power LED (~1-2 mA)
  4. Pull-up resistors on I2C lines (~0.5 mA total if both lines are pulled up)
  5. The ESP32 itself (~10 uA in deep sleep)

If you are chasing uA-level sleep current, you need to address all of them. For "weekend project on a power bank," items 1-3 do not matter.

When to use a bare module vs. a dev board

  • Dev board: prototyping, projects that plug into USB, anything that needs the serial chip for programming
  • Bare module: battery-powered projects, anything deployed, anything where you have already committed to the design

This is the project I do not cut corners on. The dev board is fine for testing; the bare module is what ships.

What to build next

  • The 18650 + TP4056 tutorial pairs with sleep for multi-month battery builds.
  • The weather station project uses deep sleep between readings.
  • The book IoT with ESP32 bundles the power tutorials.

Chapter 04

ESP32: publish MQTT messages to a broker

esp32 · 30 min

MQTT is the protocol I default to for IoT. It is light, it is well-supported, and every home automation stack speaks it. This tutorial gets you publishing from an ESP32 and subscribing from a second device on the same network.

What you need

  • Two ESP32s (one to publish, one to subscribe), or one ESP32 and the mosquitto_sub CLI on your laptop
  • A computer running an MQTT broker. I use Mosquitto because it is a one-line install on most systems and it does not get in the way.
  • The two devices need to be on the same network.

Install the broker

On a Raspberry Pi, macOS, or Linux box:

sudo apt install mosquitto      # Debian / Ubuntu
brew install mosquitto          # macOS

Then start it:

mosquitto -v

The -v flag prints every message to the terminal, which is useful for debugging. In production you would run it as a service without -v.

If you do not have a broker yet and just want to test, you can use a public broker like test.mosquitto.org, but do not publish anything you would not want the whole internet to see. It is unauthenticated.

Install the library

In the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search PubSubClient by Nick O'Leary. Install it.

The publisher

#include <WiFi.h>
#include <PubSubClient.h>

const char* ssid       = "your-wifi-ssid";
const char* password   = "your-wifi-password";
const char* mqttServer = "192.168.1.50";   // your broker IP
const int   mqttPort   = 1883;
const char* topic      = "ctrlaltbrian/sensor/temperature";

WiFiClient   wifiClient;
PubSubClient client(wifiClient);

void connectWifi() {
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
}

void connectMqtt() {
  while (!client.connected()) {
    Serial.print("Connecting to MQTT...");
    if (client.connect("esp32-publisher")) {
      Serial.println("connected");
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" retrying in 5s");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  connectWifi();
  client.setServer(mqttServer, mqttPort);
  connectMqtt();
}

unsigned long lastPublish = 0;

void loop() {
  client.loop();

  if (millis() - lastPublish > 10000) {
    lastPublish = millis();
    float temp = 22.5 + (random(-50, 50) / 10.0);   // fake reading
    char payload[32];
    snprintf(payload, sizeof(payload), "{\"temp\":%.1f}", temp);
    client.publish(topic, payload);
    Serial.println(payload);
  }
}

This publishes a JSON-ish string every 10 seconds.

The subscriber

#include <WiFi.h>
#include <PubSubClient.h>

const char* ssid       = "your-wifi-ssid";
const char* password   = "your-wifi-password";
const char* mqttServer = "192.168.1.50";
const int   mqttPort   = 1883;
const char* topic      = "ctrlaltbrian/sensor/temperature";

WiFiClient   wifiClient;
PubSubClient client(wifiClient);

void callback(char* t, byte* payload, unsigned int length) {
  Serial.print("Message on [");
  Serial.print(t);
  Serial.print("]: ");
  for (unsigned int i = 0; i < length; i++) {
    Serial.print((char)payload[i]);
  }
  Serial.println();
}

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  client.setServer(mqttServer, mqttPort);
  client.setCallback(callback);
  client.connect("esp32-subscriber");
  client.subscribe(topic);
}

void loop() {
  client.loop();
}

Upload to a second ESP32. Watch the Serial Monitor on the subscriber. You should see the publisher's messages arriving every 10 seconds.

Test from the command line

If you only have one ESP32, you can subscribe from a terminal:

mosquitto_sub -h 192.168.1.50 -t "ctrlaltbrian/#" -v

The -v prints the topic too. This is what I use for debugging more than half the time.

Topics, wildcards, and how to organize them

MQTT topics are slash-separated strings. Two wildcards:

  • + matches one level. ctrlaltbrian/sensor/+ matches ctrlaltbrian/sensor/temperature and ctrlaltbrian/sensor/humidity.
  • # matches everything below. ctrlaltbrian/# matches anything under ctrlaltbrian/.

The convention I use:

ctrlaltbrian/<room>/<device>/<measurement>

For example:

  • ctrlaltbrian/kitchen/esp32-1/temperature
  • ctrlaltbrian/kitchen/esp32-1/humidity
  • ctrlaltbrian/garage/esp32-2/door

This lets you subscribe to all of one room (ctrlaltbrian/kitchen/#) or all of one measurement type across rooms (ctrlaltbrian/+/+/temperature).

Quality of service

There are three QoS levels:

  • 0: at most once. Fire and forget. Use for things you do not care about losing (e.g. status updates that refresh every 10 seconds anyway).
  • 1: at least once. May get duplicates. Use for sensor data.
  • 2: exactly once. Slowest. Use for commands where duplicates would be bad.

For sensor publishing, QoS 0 is usually fine. The next reading will arrive in 10 seconds. For commands (e.g. "turn off the light"), use QoS 1.

When to use MQTT vs. HTTP

  • MQTT when the device is the source of truth and pushes data. Persistent connection. Low overhead per message.
  • HTTP when something needs to request data on demand, or when you are already running a web service.

Most home automation uses both. The sensors publish over MQTT, the dashboard reads them over MQTT, but the user clicks "turn off the light" by hitting an HTTP endpoint that publishes an MQTT command under the hood.

What you just built

A two-device pub/sub system that runs over your local network, with no cloud in the loop. This is the foundation for everything in the home automation book. Once you can publish and subscribe, you can wire up dashboards (Node-RED, Home Assistant, or a custom web page), you can write automations ("if temperature > 28 and time is between 14:00 and 18:00, turn on the fan"), and you can build a sensor network that does not depend on any cloud provider staying in business.

What to build next

  • The home sensor hub project ties MQTT into a full dashboard.
  • The ntfy tutorial is the notification-side companion.
  • The book IoT with ESP32 bundles the connectivity tutorials.

Chapter 05

ESP32: connect to Wi-Fi and stay connected

esp32 · 15 min

Bringing up Wi-Fi on an ESP32 is two lines of code. Keeping it connected when the router decides to be difficult is a different problem.

This tutorial is short on purpose. The whole thing is the code below plus a checklist of what to try when it does not work.

The minimum viable code

#include <WiFi.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

void setup() {
  Serial.begin(115200);
  delay(1000);   // let Serial settle
  WiFi.begin(ssid, password);

  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  // nothing to do
}

Upload, open Serial Monitor at 115200. You should see the dots, then an IP.

The reconnect logic

The minimum version above does not reconnect if the router drops the connection. For any real project, add this:

#include <WiFi.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

unsigned long lastReconnectAttempt = 0;
const unsigned long RECONNECT_INTERVAL = 30000;   // 30 seconds

void connectWifi() {
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  WiFi.mode(WIFI_STA);
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  connectWifi();
}

void checkWifi() {
  if (WiFi.status() != WL_CONNECTED) {
    unsigned long now = millis();
    if (now - lastReconnectAttempt > RECONNECT_INTERVAL) {
      lastReconnectAttempt = now;
      Serial.println("Wi-Fi lost, reconnecting...");
      connectWifi();
    }
  }
}

void loop() {
  checkWifi();
  // do your stuff
}

The 30-second interval matters. If you hammer WiFi.begin() every time loop() runs and Wi-Fi is not connected, you can wedge the chip.

Why your ESP32 will not connect

In rough order of how often I see them:

  1. Wrong SSID or password. Typos. Capitalization matters. Underscores vs. spaces. The Serial Monitor will show the connection attempt but not the reason it failed.
  2. 5GHz Wi-Fi. The ESP32 only does 2.4GHz. If your router has both bands with the same SSID, force the device onto 2.4GHz in the router settings.
  3. Captive portal. Coffee shop Wi-Fi and most guest networks. Use your phone hotspot or home Wi-Fi for testing.
  4. WPA3. Some routers default to WPA3-only. The ESP32 does WPA2. Change the router to WPA2 or WPA2/WPA3 mixed.
  5. AP isolation. On some routers, devices on Wi-Fi cannot talk to each other. This does not affect outbound connections but it will break your "browser on phone connects to ESP32" tests.
  6. MAC filtering. Some routers have it on. Add the ESP32's MAC to the allow list, or turn MAC filtering off (the right answer for a home network).

You can see the ESP32's MAC by adding this to setup():

Serial.print("MAC: ");
Serial.println(WiFi.macAddress());

How to debug a stuck connection

If you are staring at "Connecting..." with no dots and no errors, add this:

Serial.print("Status: ");
Serial.println(WiFi.status());

WiFi.status() returns:

  • 0: WL_IDLE_STATUS
  • 1: WL_NO_SSID_AVAIL (SSID not found)
  • 2: WL_SCAN_COMPLETED
  • 3: WL_CONNECTED
  • 4: WL_CONNECT_FAILED (wrong password)
  • 5: WL_CONNECTION_LOST
  • 6: WL_DISCONNECTED

Print it every few seconds. The number tells you where it is getting stuck.

Static IP vs. DHCP

Most projects use DHCP. If you need a static IP (e.g. for a known URL), configure it before WiFi.begin():

IPAddress localIP(192, 168, 1, 42);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns(8, 8, 8, 8);

WiFi.config(localIP, gateway, subnet, dns);
WiFi.begin(ssid, password);

I only do this for the "I want this device to always be at the same URL" case. For everything else, DHCP + mDNS is fine.

The mDNS trick (so you can use http://esp32.local)

After WiFi.begin(), enable mDNS:

if (MDNS.begin("esp32")) {
  Serial.println("mDNS started. Try http://esp32.local");
}

Now any device on the network can reach your ESP32 at http://esp32.local instead of fishing the IP out of the Serial Monitor. This is the single biggest quality-of-life improvement for ESP32 projects.

When the router is the bottleneck

If your ESP32 works at home but not at a different location, the problem is almost always the router. Two settings to check:

  • Beacon interval. Most home routers are fine. Some enterprise gear pushes 100ms beacons which can confuse older ESP32 firmwares.
  • DTIM interval. Higher DTIM = more battery life for clients but worse for ESP32 deep sleep wakeup latency.

You usually do not need to change these. But if you are deploying ESP32s across multiple sites and some of them are flaky, these are the levers.

What to build next

  • The MQTT tutorial publishes your first data.
  • The web server tutorial serves it to the LAN.
  • The book IoT with ESP32 bundles the foundations.

Chapter 06

ESP32: read an HC-SR04 ultrasonic distance sensor

esp32 · 20 min

The HC-SR04 is the sensor I reach for when I need to know how far away something is. It is cheap (about $1.50), it works on 5V, and it has been around forever.

This tutorial covers the wiring, the math, and the part most tutorials skip: why the ESP32's 3.3V logic needs a small workaround for the trigger pin.

What you need

  • ESP32 dev board
  • HC-SR04 ultrasonic sensor
  • Jumper wires
  • 1k and 2k resistors (for the voltage divider on the echo pin)

Wiring

HC-SR04 Connect to
VCC 5V on ESP32
GND GND on ESP32
TRIG GPIO 5 on ESP32 (direct, 3.3V logic is fine)
ECHO Voltage divider to GPIO 18 on ESP32

The voltage divider is the part you do not want to skip. The HC-SR04's echo line outputs 5V, but the ESP32's GPIO is 3.3V-only. Apply 5V to a GPIO and you will let the magic smoke out, eventually.

The voltage divider is two resistors on the echo line:

ECHO --[ 1k ]--+-- GPIO 18
               |
             [ 2k ]
               |
              GND

That divides the 5V echo signal down to about 3.3V, which is safe for the ESP32. Use the same divider on every HC-SR04 project.

Install

No library needed. The Arduino IDE has pulseIn() built in.

The code

ESP32 (Arduino)

#define TRIG_PIN 5
#define ECHO_PIN 18

long duration;
float distanceCm;

void setup() {
  Serial.begin(115200);
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
}

void loop() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  duration = pulseIn(ECHO_PIN, HIGH, 30000);
  if (duration == 0) {
    Serial.println("Out of range");
  } else {
    distanceCm = duration * 0.0343 / 2.0;
    Serial.print("Distance: ");
    Serial.print(distanceCm);
    Serial.println(" cm");
  }

  delay(100);
}

The HC-SR04 runs on 5V. The ECHO pin outputs 5V. On the ESP32 (3.3V GPIO), add a voltage divider on ECHO: 1k ohm + 2k ohm, with the midpoint going to GPIO 18. The Uno's 5V GPIO can take 5V directly.

Arduino (Uno, Nano, Mega)

#define TRIG_PIN 3   // any digital pin
#define ECHO_PIN 2   // any digital pin

long duration;
float distanceCm;

void setup() {
  Serial.begin(9600);
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
}

void loop() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  duration = pulseIn(ECHO_PIN, HIGH, 30000);
  if (duration == 0) {
    Serial.println("Out of range");
  } else {
    distanceCm = duration * 0.0343 / 2.0;
    Serial.print("Distance: ");
    Serial.print(distanceCm);
    Serial.println(" cm");
  }
  delay(100);
}

MicroPython (ESP32 or Pico)

from machine import Pin, time_pulse_us
import time

trig = Pin(5, Pin.OUT)
echo = Pin(18, Pin.IN)

while True:
    trig.low()
    time.sleep_us(2)
    trig.high()
    time.sleep_us(10)
    trig.low()

    us = time_pulse_us(echo, 1, 30_000)
    if us < 0:
        print('Out of range')
    else:
        print(f'Distance: {us * 0.0343 / 2.0:.1f} cm')
    time.sleep(0.1)

Raspberry Pi Python

The Pi's GPIO does not have a hardware pulseIn equivalent; timing is done in software. Less accurate than the C versions.

import gpiozero
import time

trig = gpiozero.OutputDevice(5)   # BCM pin 5
echo = gpiozero.DigitalInputDevice(18, pull_up=False)   # BCM pin 18

def read_distance_cm():
    trig.off()
    time.sleep(0.000002)
    trig.on()
    time.sleep(0.00001)
    trig.off()

    start = time.time()
    while not echo.is_active:
        if time.time() - start > 0.03:
            return None
    pulse_start = time.time()

    while echo.is_active:
        if time.time() - pulse_start > 0.03:
            return None
    pulse_end = time.time()

    duration = (pulse_end - pulse_start) * 1_000_000
    return duration * 0.0343 / 2.0

while True:
    dist = read_distance_cm()
    if dist is None:
        print('Out of range')
    else:
        print(f'Distance: {dist:.1f} cm')
    time.sleep(0.1)

What you should see

Upload it. Wave your hand in front of the sensor. You should see the distance change.

The math

Sound travels at about 343 m/s in air at room temperature. That is 0.0343 cm per microsecond. The round trip (out and back) takes twice as long, so:

distance_cm = duration_us * 0.0343 / 2

If you want temperature compensation:

float speedOfSound = 0.0331 + 0.00006 * temperatureC;
distanceCm = duration * speedOfSound / 2.0;

For indoor projects at room temperature, the basic version is fine. For outdoor projects where the temperature changes a lot, compensate.

The 30ms timeout

pulseIn(ECHO_PIN, HIGH, 30000) waits up to 30 ms for the echo. The sensor is rated for 4 m max range, which is about 23 ms round trip. 30 ms gives a bit of margin. If pulseIn() returns 0, the object is out of range (or the wiring is wrong).

Common issues

  • Always reads zero. Wiring is wrong. Check the voltage divider.
  • Always reads 0 or max. The echo pin is on the wrong GPIO, or the voltage divider is missing.
  • Reads once, then stuck. Power issue. The HC-SR04 draws 15 mA when pulsing. If you are powering it from the ESP32's 5V pin, you should be fine. If you are powering from a USB hub, try a different port.
  • Reads are noisy. Average several readings and discard outliers:
float readDistance() {
  float samples[5];
  for (int i = 0; i < 5; i++) {
    digitalWrite(TRIG_PIN, LOW); delayMicroseconds(2);
    digitalWrite(TRIG_PIN, HIGH); delayMicroseconds(10);
    digitalWrite(TRIG_PIN, LOW);
    samples[i] = pulseIn(ECHO_PIN, HIGH, 30000) * 0.0343 / 2.0;
    delay(20);
  }
  // discard outliers
  float sum = 0;
  for (int i = 0; i < 5; i++) sum += samples[i];
  return sum / 5.0;
}

When to use the HC-SR04 vs. alternatives

  • HC-SR04: cheap, 4m range, 5V power, sensitive to soft surfaces (e.g. fabric absorbs the ping)
  • VL53L0X: laser time-of-flight, 2m range, 3.3V, much smaller, less sensitive to surface
  • VL53L1X: same family, 4m range
  • HC-SR05: smaller cousin, 3.3V-compatible, but rarer

For "is there something in front of me" robot projects, HC-SR04 is fine. For "what is the exact distance to this surface," VL53L0X is better.

What to build next

  • A parking sensor with a buzzer that beeps faster as you get closer.
  • A water level sensor in a tank (downward-facing HC-SR04 on the lid).
  • A trash can that opens when your hand is within 10 cm.

The parking sensor version is in the book ESP32 Robotics Projects. The water level sensor is one of the next tutorials on this site.


Chapter 07

ESP32: use the onboard touch pins as buttons

esp32 · 20 min

The ESP32 has 10 capacitive touch pins (T0 through T9) that work without any extra hardware. Touch one with your finger (or a piece of foil connected to it), and the chip detects the capacitance change. No pull-up resistor, no debouncing, no button to buy.

This is the tutorial I send people when they want a button without a button.

What you need

  • ESP32 dev board
  • A piece of aluminum foil and a wire, OR a wire that you can touch with your finger

That is it. No resistors. No buttons.

The touch pins

On a 30-pin ESP32 dev board, the touch pins are:

  • GPIO 4 (T0)
  • GPIO 0 (T2): careful, this is a boot pin
  • GPIO 2 (T2): careful, this is a boot pin
  • GPIO 15 (T3): careful, this is a boot pin
  • GPIO 13 (T4)
  • GPIO 12 (T5)
  • GPIO 14 (T6)
  • GPIO 27 (T7)
  • GPIO 33 (T8)
  • GPIO 32 (T9)

GPIO 4 and GPIO 13-33 are the safe picks. GPIO 0, 2, and 15 do weird things during reset and will cause confusing boot failures if you tie them to a big piece of metal.

The code

#define TOUCH_PIN T0   // GPIO 4

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Touch the pin!");
}

void loop() {
  int value = touchRead(TOUCH_PIN);
  Serial.println(value);
  delay(100);
}

Upload it. Open Serial Monitor. You should see a baseline value (the "untouched" reading, usually around 60-80). Touch the pin and the number drops (toward 10-20). Move your hand away and it goes back.

Every board is slightly different. The baseline for your board might be 70 or 80. The "touched" threshold depends on the value you see.

A more useful version: print "touched" vs "not touched"

#define TOUCH_PIN T0
int threshold = 30;   // adjust based on your baseline

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Touch the pin!");
}

void loop() {
  int value = touchRead(TOUCH_PIN);
  if (value < threshold) {
    Serial.println("Touched!");
    while (touchRead(TOUCH_PIN) < threshold) delay(50);
  } else {
    Serial.println("...");
  }
  delay(50);
}

The while loop waits for you to remove your finger, so you only get one "Touched!" per touch.

How to set the threshold

Run the first sketch (the one that just prints the value). Watch the Serial Monitor. Note the highest value when not touched, and the lowest value when touched. Set the threshold in between.

For example:

  • Untouched: 70-80
  • Touched: 10-25

Threshold of 40-50 works.

If the threshold is too high, you get false positives from noise. If too low, you have to touch really hard to trigger it.

Adding a debounce

Capacitive touch can flicker when your finger is on the edge of detection. Add a debounce:

bool touchState = false;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 150;

void loop() {
  int value = touchRead(TOUCH_PIN);
  bool touched = value < threshold;

  if (touched != touchState && millis() - lastChange > DEBOUNCE_MS) {
    touchState = touched;
    lastChange = millis();
    if (touchState) Serial.println("Touched!");
  }
}

Putting foil on the touch pin

To make a "hidden" touch button, connect a wire from T0 to a piece of aluminum foil taped under a non-conductive surface (e.g. under a piece of tape, under a 3D-printed panel). Touching the surface touches the button.

This is how you make:

  • Lamp controls hidden in a wood panel
  • Touch interfaces that look like they are doing magic
  • Buttons behind glass or plastic

The capacitance of the wire matters. Keep the wire under about 30 cm, and keep it away from other wires or PCB traces, which add parasitic capacitance and can make the touch sensor less sensitive.

What to build next

  • A touch-controlled lamp (touch the nightstand, lamp turns on).
  • A multi-touch piano with one touch pin per key.
  • A touch-controlled volume slider (linear foil strip with multiple touch pins).

The lamp version is a separate tutorial on this site. The multi-touch version is in the book ESP32 Fun Projects.


Chapter 08

ESP32: OTA (over-the-air) firmware updates

esp32 · 30 min

OTA (over-the-air) updates are the feature that takes an ESP32 project from "plugged into my laptop" to "deployed on the ceiling of the garage." Once OTA is set up, you can update the firmware from your laptop without climbing a ladder.

This tutorial is the minimum version. It uses Arduino OTA, which is built in to the ESP32 Arduino core.

What you need

  • An ESP32 you can currently program over USB
  • A computer on the same Wi-Fi network

The code

#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  ArduinoOTA.setHostname("esp32-ota-test");
  ArduinoOTA.begin();

  Serial.print("Ready. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  ArduinoOTA.handle();
  // your normal code here
}

Upload it. Open Serial Monitor. Note the IP. Now go to the Arduino IDE.

Configure the Arduino IDE for OTA

  1. Select Tools >> Port. You should see a new entry under "Network ports" that says something like esp32-ota-test at 192.168.1.42. Select that.
  2. Click Upload.

The IDE sends the firmware over the network. The ESP32 reboots into the new firmware. You did not touch the USB cable.

What happens during an OTA update

  1. The ESP32 listens on port 3232 (Arduino OTA default).
  2. The IDE connects, sends the new firmware in chunks.
  3. The ESP32 writes the new firmware to a second partition.
  4. When the upload is complete, the ESP32 sets a flag and reboots.
  5. The bootloader sees the flag, swaps partitions, and boots the new firmware.

If the new firmware is broken (e.g. crashes on boot), the ESP32 rolls back to the previous one automatically. The roll-back is built in to the partition scheme.

Setting a password

Without a password, anyone on your Wi-Fi can upload to your ESP32. Add a password:

ArduinoOTA.setPassword("your-password-here");

You will be prompted for the password in the Arduino IDE when you upload.

Setting a port

If 3232 conflicts with something else on your network:

ArduinoOTA.setPort(3232);   // pick something else

Why OTA updates fail

  • The ESP32 and the IDE are on different Wi-Fi networks. Check both are on the same SSID. Some networks isolate clients from each other (guest networks, IoT VLANs).
  • The IDE is showing the wrong network port. After rebooting the ESP32, the IDE may still have the old entry. Click the port dropdown and re-select.
  • The mDNS hostname is conflicting. If you have two ESP32s with the same hostname, only one shows up in the IDE. Set unique hostnames:
ArduinoOTA.setHostname("esp32-garage-sensor");
  • Firewall on the laptop. Some firewalls block port 3232. Allow it or temporarily disable the firewall for testing.

Adding a manual OTA trigger

For deployed devices, I add a "double-tap reset" or a button press that enables OTA only for 60 seconds. That way a stray update can never brick a deployed device:

#define OTA_BUTTON_PIN 0   // GPIO 0 (the BOOT button on most boards)

unsigned long otaWindowStart = 0;
const unsigned long OTA_WINDOW_MS = 60000;

void setup() {
  // ... after ArduinoOTA.begin() ...

  pinMode(OTA_BUTTON_PIN, INPUT_PULLUP);
}

void checkOtaTrigger() {
  if (digitalRead(OTA_BUTTON_PIN) == LOW) {
    otaWindowStart = millis();
  }
}

void loop() {
  checkOtaTrigger();

  if (millis() - otaWindowStart < OTA_WINDOW_MS) {
    ArduinoOTA.handle();   // OTA only available during window
  }
  // ... your normal code ...
}

Hold the BOOT button on most ESP32 boards and OTA becomes available for 60 seconds. Let go of the button (or wait 60 seconds) and OTA is locked again.

When to use OTA vs. a custom update mechanism

  • Arduino OTA for development and hobbyist projects. Easy. Built in.
  • HTTP OTA (e.g. fetch a firmware.bin from a URL) for production deployments. Lets you push updates to a fleet of devices from a server.
  • ESP-IDF OTA if you need signed firmware, encrypted firmware, or rollback to a specific version.

The HTTP OTA version is in the book ESP32 in Production. The ESP-IDF version is in the Learn ESP-IDF book.

What you just built

The single most useful deployment feature for ESP32 projects. Combine this with deep sleep (covered in a separate tutorial) and you have a sensor network that you can update without unplugging anything.

What to build next

  • The OTA signing tutorial secures the update channel.
  • The NVS storage tutorial persists state across updates.
  • The book IoT with ESP32 bundles the foundations.

Chapter 09

ESP32: read buttons and debounce them in software

esp32 · 20 min

A button is the simplest input. It is also the source of more weird bugs than any other component. The button is not "broken." The button is bouncing, and your code is reading the bounce.

This tutorial covers the wiring, the pull-up resistor (or why you do not need one on the ESP32), and the debounce pattern I copy-paste into every project.

What you need

  • ESP32 dev board
  • A momentary pushbutton (the four-leg tactile switches are fine)
  • A wire, or just the button's leads

No external resistors needed. The ESP32 has internal pull-ups.

Wiring

Connect one leg of the button to GPIO 4. Connect the other leg to GND.

That is the entire wiring. The internal pull-up (enabled in code) holds the GPIO high when the button is not pressed. When you press the button, the GPIO goes low. When you release, it goes high again.

The bouncing problem

When you press a button, the contacts do not close cleanly. They bounce for a few milliseconds, which means the GPIO sees high-low-high-low-high before settling low. If you read the GPIO every millisecond, you will see multiple presses for one physical press.

You can see this with a quick sketch:

#define BTN_PIN 4

void setup() {
  Serial.begin(115200);
  pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
  if (digitalRead(BTN_PIN) == LOW) {
    Serial.println("Pressed");
    delay(50);
  }
}

Press the button once. You might see "Pressed" printed 2-4 times. That is the bounce.

The debounce pattern

There are a lot of debounce libraries. I have tried most of them. The one I actually use in production is a state-machine pattern in plain code, because I can read it later and remember what it does.

#define BTN_PIN 4

enum ButtonState { IDLE, PRESSED, RELEASED };
ButtonState state = IDLE;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 30;

bool buttonPressed = false;   // set true on press, you clear it

void setup() {
  Serial.begin(115200);
  pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
  bool reading = digitalRead(BTN_PIN) == LOW;   // LOW = pressed

  switch (state) {
    case IDLE:
      if (reading) {
        state = PRESSED;
        lastChange = millis();
      }
      break;

    case PRESSED:
      if (millis() - lastChange > DEBOUNCE_MS) {
        if (reading) {
          // confirmed press
          buttonPressed = true;
          state = RELEASED;
        } else {
          // it was a bounce, go back to idle
          state = IDLE;
        }
      }
      break;

    case RELEASED:
      if (!reading) {
        // wait for the release to settle
        state = IDLE;
        lastChange = millis();
      }
      break;
  }

  if (buttonPressed) {
    Serial.println("Press detected");
    buttonPressed = false;
  }
}

The pattern:

  • IDLE: waiting for a press. When we see LOW, move to PRESSED and start a timer.
  • PRESSED: waiting for the debounce window to expire. After 30 ms, check the pin again. If still pressed, it was a real press. If not, it was a bounce.
  • RELEASED: waiting for the button to be released. Once it is, go back to IDLE.

30 ms is a good default. Mechanical buttons usually bounce for 5-15 ms. Membrane switches and cheaper buttons can bounce longer.

Edge-triggered vs. level-triggered

The pattern above is edge-triggered: it fires once on the press. Some projects want level-triggered behavior (e.g. "while the button is held, keep doing X"). For that, check the raw reading:

if (digitalRead(BTN_PIN) == LOW) {
  // button is currently being held
}

Multiple buttons

The pattern scales. One state, lastChange, and reading per button:

#define BTN1_PIN 4
#define BTN2_PIN 5
#define BTN3_PIN 18

struct Button {
  int pin;
  bool pressed;
  unsigned long lastChange;
  int state;   // 0=IDLE, 1=PRESSED, 2=RELEASED
};

Button buttons[] = {
  {BTN1_PIN, false, 0, 0},
  {BTN2_PIN, false, 0, 0},
  {BTN3_PIN, false, 0, 0},
};
const int NUM_BUTTONS = 3;

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < NUM_BUTTONS; i++) {
    pinMode(buttons[i].pin, INPUT_PULLUP);
  }
}

void loop() {
  for (int i = 0; i < NUM_BUTTONS; i++) {
    Button &b = buttons[i];
    bool reading = digitalRead(b.pin) == LOW;
    switch (b.state) {
      case 0:
        if (reading) { b.state = 1; b.lastChange = millis(); }
        break;
      case 1:
        if (millis() - b.lastChange > 30) {
          if (reading) { b.pressed = true; b.state = 2; }
          else { b.state = 0; }
        }
        break;
      case 2:
        if (!reading) { b.state = 0; b.lastChange = millis(); }
        break;
    }
    if (b.pressed) {
      Serial.print("Button "); Serial.print(i); Serial.println(" pressed");
      b.pressed = false;
    }
  }
}

Pull-up vs. pull-down

I use pull-up (button to GND) because:

  • The ESP32's internal pull-ups work fine. Pull-downs are weaker.
  • Wiring is simpler: one GPIO, one GND, no resistor.
  • Pressed = LOW is a common convention, and the code reads more clearly when "pressed" is the active-low state.

When the button is on a long wire

If the button is more than a meter or so from the ESP32, you can get noise on the line. Two fixes:

  1. Add a small ceramic capacitor (100 nF) across the button.
  2. Use shielded cable for the button wire.

For most projects (e.g. a button on a project box), neither is needed. I have used 3 m of unshielded wire for a button and it worked fine.

What to build next

  • A menu system with multiple buttons (up, down, select).
  • A wake-from-sleep button (combine with the deep sleep tutorial).
  • A long-press handler (different actions for short vs. long press).

The wake-from-sleep version is in the book ESP32 Low Power. The menu system is in the book ESP32 UI Patterns.


Chapter 10

ESP32: install the toolchain the right way

esp32 · 15 min

Most "how to install the ESP32 board" tutorials stop after the boards manager install. They miss the parts that come back to bite you later: the USB driver that loads on one machine but not another, the port that disappears after a firmware update, the bootloader that gets stuck and needs a manual reset.

This tutorial is the one I wish I had when I started. It is the same five steps every tutorial tells you to do, plus the five fixes for the parts that break later.

What you need

  • An ESP32 dev board. Any variant with a USB port will work. The ESP32-DevKitC and the NodeMCU-32S are the most common picks.
  • A USB cable. Data, not charge-only. The cable that came with your phone charger is probably charge-only. The one that came with a real device is data.
  • A computer. macOS, Windows, or Linux. The install steps differ slightly.

Step 1: install the Arduino IDE

Download the regular Arduino IDE from https://www.arduino.cc/en/software. Not the Web Editor. Not Arduino Create. The standalone IDE you can run offline.

Version 2.x is current. It is faster than 1.x and the board manager works the same way. If you have 1.x installed from a previous project, leave it. The two coexist fine.

Step 2: add the ESP32 board package URL

Open the IDE and go to File >> Preferences (macOS: Arduino >> Preferences). Look for the field labeled Additional boards manager URLs. Paste this:

https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json

If you already have URLs in the field (e.g. for an Adafruit or STM32 board), click the icon next to the field and add this URL on a new line. The board manager supports multiple URLs.

Click OK.

Step 3: install the ESP32 platform

Tools >> Board >> Boards Manager. Wait for the index to load (this takes 30-60 seconds the first time). Search for esp32. The package you want is esp32 by Espressif Systems. Click Install.

This downloads about 250 MB. Go make coffee. The install takes 2-5 minutes depending on your network.

The version number matters less than you think. Anything 2.x or 3.x is fine. The 1.0.x line is end-of-life and missing a lot of recent boards. If a tutorial says "tested with version X.Y" and you have a different version, the code almost always works anyway.

After install, the IDE needs a full restart. File >> Quit, then reopen. This is the part where the install quietly fails for half of people, and they think the board package did not work.

Step 4: pick your board

Tools >> Board >> esp32. You will see a long list. The right pick:

  • ESP32 Dev Module: the generic pick. Works for most dev boards.
  • ESP32 Wrover Module: if your board has 4 MB or more of PSRAM.
  • NodeMCU-32S: if you have that specific board. Same silicon, slightly different pin assignments in the IDE defaults.
  • ESP32-S3 Dev Module: the newer USB-native boards. You will know if you have one.

If you do not know which, ESP32 Dev Module is the safe default.

Step 5: pick the port

Tools >> Port. The options depend on your OS:

  • macOS: /dev/cu.usbserial-XXXX or /dev/cu.SLAB_USBtoUART
  • Windows: COM3, COM4, etc. The number is not predictable.
  • Linux: /dev/ttyUSB0 or /dev/ttyACM0

If you see nothing, the USB driver is not installed (more on this below).

Pick the port. The IDE remembers it per board, so once you have it set, it sticks.

Step 6: confirm with a blink sketch

File >> Examples >> 01.Basics >> Blink. This opens the canonical blink sketch.

Find this line:

int led = LED_BUILTIN;

Replace LED_BUILTIN with the GPIO number for your board's onboard LED. For most ESP32 dev boards, that is 2. For the ESP32-S3, that is 48. For the ESP32-C3, that is 8.

Upload. The onboard LED should blink at 1 Hz.

If you do not know your board's LED pin, search for "[your board name] onboard LED pin" in the ESP32 forum. The answer is one search away.

The five things that break later

1. The USB driver does not load on Windows

The ESP32 uses either the CP2102 or the CH340 USB-serial chip, depending on which board you bought. Most modern Windows installs have the driver already. Some do not.

If the port does not show up:

  • Look in Device Manager >> Ports (COM & LPT).
  • An unknown device with a yellow triangle is the ESP32 with no driver.
  • Download the CP2102 driver from Silicon Labs' site, or the CH340 driver from WCH's site. Both are free.

On macOS and Linux, the driver is built in. Skip this section.

2. The port disappears after a firmware crash

If you upload a sketch that crashes the USB stack, the port vanishes and the IDE cannot find it. Fix:

  1. Hold the BOOT button on the ESP32.
  2. Press and release the RESET button (still holding BOOT).
  3. Release the BOOT button.
  4. Try uploading again.

This puts the ESP32 in download mode. The port should reappear.

3. The IDE hangs on "Uploading..."

This is the same problem as #2. The ESP32 is not in download mode. Hold BOOT, press RESET, release BOOT, then click Upload.

Some boards have a different boot behavior. The ESP32-S3 has a single button that does both. The S2 has different pins. Check your specific board's pinout if the standard procedure does not work.

4. The wrong board package version

If a tutorial says "tested with ESP32 Arduino Core 2.0.14" and you have 3.0.x, some examples may behave differently. The library version mismatch usually shows up as compile errors mentioning deprecated APIs.

Two fixes:

  • Update the libraries the tutorial uses. Tools >> Manage Libraries, search for the library, install latest.
  • If the tutorial is too old to work with your board package, downgrade in the Boards Manager (click the version dropdown next to the installed package).

5. The "exit status 1" upload error

This means the upload failed, usually for one of three reasons:

  • Port not selected (or wrong port).
  • Board not selected (or wrong board).
  • The ESP32 is not in download mode (see #2 and #3).

The full error text usually points to which one. Read it. The fix is usually a 5-second adjustment.

What you learned

The 6-step install flow: IDE download, board manager URL, package install, board select, port select, blink-test. Plus the 5 fixes for the parts that go wrong after the install "succeeds."

The pattern here is the same one you will see for every board: install, configure, test, fix the parts the tutorial author did not warn you about.

When something else breaks

  • Compiles but does nothing on the chip. The right board is not selected. Double-check Tools >> Board.
  • Compiles, uploads, but the LED blinks wrong color or does not blink. The pin number for LED_BUILTIN is wrong for your board. Look up your specific board's LED GPIO.
  • Sketch compiles for 10 minutes. The first compile on a new ESP32 project takes 1-3 minutes. Subsequent compiles are 10-30 seconds. If it is consistently slow, close and reopen the IDE.

What to build next

  • The next tutorial is GPIO basics (links at the bottom). You will need this working install before you can blink an LED on a pin you picked, not just LED_BUILTIN.
  • The boot pins tutorial is worth reading even if everything works. It explains what the BOOT button actually does, which is the single most useful debugging skill for the ESP32.

Chapter 11

ESP32: the boot button and boot pins, what they actually do

esp32 · 15 min

The first time an ESP32 sketch will not upload, the fix is to hold BOOT, press RESET, release RESET, release BOOT. The second time, you forget which order. The third time, you hold both buttons for the entire upload. The fourth time, you google it again.

This tutorial is the one I wish I had read first. It is what the BOOT button actually does, why some sketches need it and others do not, and the exact sequence to put the chip in download mode without wanting to throw it out the window.

What the BOOT button is

On most ESP32 dev boards, there is a button labeled BOOT or IO0 (they are usually the same thing). It connects GPIO 0 to ground when pressed.

GPIO 0 is a special pin on the ESP32. During reset, the chip checks its state:

  • GPIO 0 LOW (button pressed): boot into download mode. The chip waits for new firmware over the serial port.
  • GPIO 0 HIGH (button released): boot normally. The chip runs whatever firmware is in flash.

That is the entire job of the BOOT button. It is not a reset button. It is a "tell the chip to listen for new firmware" button. Pressing it does not reset the ESP32; it just holds GPIO 0 low during the next reset.

What the RESET button is

The RESET button (sometimes labeled EN or RST) actually resets the chip. It pulls the EN pin low briefly, which causes a normal restart.

The standard sequence for upload when the auto-upload fails:

  1. Hold BOOT (GPIO 0 is now LOW).
  2. Press and release RESET (chip restarts, sees GPIO 0 LOW, enters download mode).
  3. Release BOOT.
  4. Click Upload in the IDE.

The ESP32 is now in download mode and the upload will succeed. After the upload, the chip automatically restarts into the new firmware.

Some boards do not have a separate RESET button. The ESP32-S3, for example, has a single button that does both BOOT and RESET. Press it for 1 second, release, then click Upload. Check your board's pinout.

When you need the manual sequence

The Arduino IDE normally resets the ESP32 automatically before uploading. It uses the serial port's DTR and RTS pins to do this. On most boards, this works.

It does not work when:

  • The USB-serial chip is missing or broken.
  • The board's auto-reset circuit has a wrong resistor value (rare, but common on cheap clones).
  • The previous sketch crashed the USB stack so badly that the auto-reset cannot talk to the chip.
  • The GPIO 0 pin is being held low by something else (e.g. a button you wired up without thinking about the boot implications).

In all those cases, the manual sequence saves you.

The boot pins you need to know about

The ESP32 has a few pins that do special things during reset. Use them carefully.

GPIO During reset Notes
GPIO 0 LOW = download mode The BOOT button pin
GPIO 2 Must be floating or LOW Often has onboard LED; careful with that
GPIO 12 Selects flash voltage (LOW = 1.8V, HIGH = 3.3V) Affects boot behavior; tie high for normal use
GPIO 15 Must be HIGH during boot Often has a pulldown on dev boards; usually OK to use

The rules for the data pins (GPIO 4, GPIO 5, GPIO 13, etc.) are simpler: avoid them only if you have a specific reason. GPIO 34, 35, 36, 39 are input-only; do not try to drive them as outputs.

If you wire a button or LED to GPIO 2 and it does not work, that is why. GPIO 2 must be in a specific state during reset for the chip to boot normally. Many dev boards put the onboard LED on GPIO 2 because it is convenient; the LED is OK because it is just an LED and does not hold the pin in a weird state.

What happens when an upload fails

When the IDE tries to upload and the ESP32 is not in download mode:

  1. The IDE sends the "enter download mode" sequence over serial.
  2. The ESP32 ignores it (it is running user firmware).
  3. The IDE times out and prints "Failed to connect to ESP32: Timed out waiting for packet header."

That error message means "the chip is not in download mode." The fix is almost always the BOOT + RESET sequence.

The exception: ESP32-S2 and ESP32-S3

The newer ESP32 variants have USB-OTG built in. They do not need a USB-serial chip; the USB connector plugs directly into the ESP32.

The download mode procedure is different:

  • ESP32-S2: hold BOOT, press RESET, release RESET, release BOOT.
  • ESP32-S3: press and hold the single button (it does both BOOT and RESET in one), release after 1 second.

Otherwise the same pattern applies: BOOT during reset = download mode.

How to make the BOOT button useful

On most boards, the BOOT button is on the top side and easy to hit accidentally. A 10-second accidental press can put the chip into a state where your project does not boot.

Two fixes:

  1. Read the pin before booting up. Add this to setup():
pinMode(0, INPUT_PULLUP);
if (digitalRead(0) == LOW) {
  Serial.println("BOOT held at startup, entering config mode");
  // do whatever your config mode does
}
  1. Disable GPIO 0 as a usable pin in your firmware. Just leave it as the BOOT pin and never wire anything to it. That way an accidental press cannot put your project into a broken state.

What you learned

The BOOT button is GPIO 0 held low during reset, which puts the ESP32 into download mode. The RESET button (or EN) restarts the chip. The manual upload sequence is BOOT + RESET + release BOOT. The boot pins (GPIO 0, GPIO 2, GPIO 12, GPIO 15) do special things at reset, so be careful wiring things to them.

This is one of those tutorials that does not feel important until the day your upload silently fails. Then it is the most important thing you have ever read.

When something breaks

  • Upload fails after working yesterday. You changed a pin assignment and started driving GPIO 2 or GPIO 12 weirdly. Check what is wired to those pins.
  • The chip will not boot at all. GPIO 0 is stuck LOW (maybe a wire is shorting it to ground, or a button you added is latched). Find and remove the short.
  • The chip boots but the sketch does not run. The watchdog is firing. Add disableCore0WDT() or extend the timeout. (More on this in a separate tutorial.)
  • You need to reflash but cannot get into download mode. Some boards have a "flash" or "prog" button that is different from BOOT. Check the silkscreen. Some clones also have weird reset circuits.

What to build next

  • The GPIO basics tutorial covers digital read, digital write, and the internal pull-up trick. Most projects start there.
  • The deep sleep tutorial uses the boot pins implicitly because it controls what runs after wake-up.

Chapter 12

ESP32: analog read with the ADC, and why ADC2 is broken on Wi-Fi

esp32 · 25 min

The ESP32 has two ADCs (analog-to-digital converters) with about a dozen usable analog input pins. They read voltages from 0 to 3.3V and return a 12-bit value (0 to 4095). That sounds simple. The trap is that ADC2 is shared with the Wi-Fi radio, and you cannot use ADC2 while Wi-Fi is active. This is the single most common "my sensor reads 0 over Wi-Fi" bug.

This tutorial covers both ADCs, the Wi-Fi conflict, the input-only pins, and the calibration that makes your readings mean something.

What you need

  • An ESP32 dev board
  • A 10k ohm potentiometer (the panel-mount kind with three legs)
  • Three jumper wires

Wiring

Potentiometer left leg  -- GND
Potentiometer middle leg -- GPIO 34 (or any ADC pin)
Potentiometer right leg  -- 3.3V

GPIO 34 is one of the input-only pins, which makes it a great default for ADC readings. You cannot accidentally drive it as output, so there is no risk of shorting the pot.

Turn the knob all the way one way, you should read 0. All the way the other way, you should read 4095. Halfway, around 2048.

The code

const int POT_PIN = 34;

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);   // 0-4095, default already
}

void loop() {
  int raw = analogRead(POT_PIN);
  Serial.println(raw);
  delay(100);
}

Upload. Open Serial Monitor at 115200 baud. Turn the knob. You should see the value change from 0 to 4095.

What the 4095 actually means

The ESP32's ADC is 12-bit, which means it divides the 0-3.3V input range into 4096 steps. Each step is about 0.8 mV.

Reading Voltage
0 0 V
1024 ~0.83 V
2048 ~1.65 V
3072 ~2.48 V
4095 ~3.3 V

For real-world use, you almost never care about the raw reading. You care about the voltage, or the percentage, or the sensor-specific value (temperature, light level, etc.). Convert:

float voltage = raw * 3.3 / 4095.0;
float percent = raw / 4095.0 * 100.0;

The Wi-Fi trap (the most common ADC bug)

ADC1 (GPIO 32-39) and ADC2 (GPIO 0, 2, 4, 12-15, 25-27) are different controllers. ADC2 is shared with the Wi-Fi radio. When Wi-Fi is active, ADC2 reads are silently broken: the driver returns 0 or garbage, with no error.

If you are building a Wi-Fi project and your analog sensor reads zero all the time, this is why. The fix is one of two:

  1. Use ADC1 pins. GPIO 32-39 are ADC1 only. Use those for any sensor that needs to work while Wi-Fi is running.
  2. Stop Wi-Fi before reading. Not realistic for most projects.

I default to ADC1 (GPIO 32-39) for every analog sensor I wire up. It removes a class of bugs.

The input-only pin caveat

GPIO 32-39 are ADC1, but they are also input-only. You cannot drive them as output. For analog input (which is the use case), this is fine. For anything else, pick a different pin.

The full ADC1 pin list: GPIO 32, 33, 34, 35, 36, 37, 38, 39. On most boards, 37 and 38 are not exposed (they are used for flash on some modules). The practical list is 32, 33, 34, 35, 36, 39.

The accuracy problem

The ESP32's ADC is notoriously inaccurate. The factory calibration is good to about ±5%, which is fine for "is the knob turned up" but not for "is the temperature 24.3 C or 25.1 C."

For higher accuracy, the standard fix is a multi-point calibration:

// Take 10 readings, drop the highest and lowest, average the rest
int readSmooth(int pin) {
  const int N = 10;
  int samples[N];
  for (int i = 0; i < N; i++) {
    samples[i] = analogRead(pin);
    delay(5);
  }
  // sort
  for (int i = 0; i < N - 1; i++) {
    for (int j = i + 1; j < N; j++) {
      if (samples[i] > samples[j]) {
        int t = samples[i]; samples[i] = samples[j]; samples[j] = t;
      }
    }
  }
  // average middle 6 (drop 2 highest and 2 lowest)
  long sum = 0;
  for (int i = 2; i < N - 2; i++) sum += samples[i];
  return sum / (N - 4);
}

That gets you ±2% without much extra code.

For higher accuracy, use an external ADC chip like the ADS1115 (16-bit, I2C, about $2). That is in the book Production IoT with ESP32.

Using analogSetAttenuation

The ESP32 ADC can read a wider range if you change the attenuation. By default it reads 0 to about 1.1V accurately. For 0-3.3V, use the 11 dB attenuation:

analogSetPinAttenuation(POT_PIN, ADC_11db);   // 0 to ~3.3V full scale

Without this, readings above about 1V start clipping. If you are reading a potentiometer connected between GND and 3.3V, you almost certainly need ADC_11db.

Other options:

  • ADC_0db: full scale ~1.1V (best accuracy for low voltages)
  • ADC_2_5db: full scale ~1.5V
  • ADC_6db: full scale ~2.2V
  • ADC_11db: full scale ~3.3V (default for most projects)

Pick the smallest range that covers your signal. Less attenuation = more accuracy but smaller range.

Reading multiple sensors

For multiple analog sensors, just call analogRead on different pins:

const int TEMP_PIN = 34;
const int LIGHT_PIN = 35;

void loop() {
  int tempRaw = analogRead(TEMP_PIN);
  int lightRaw = analogRead(LIGHT_PIN);
  // ...
  delay(100);
}

Each analogRead takes about 30 us. You can read a dozen sensors at about 3 kHz total.

What you learned

  • The ESP32 has 12-bit ADCs (0-4095 for 0-3.3V).
  • Use ADC1 pins (GPIO 32-39) when Wi-Fi is active. ADC2 is broken with Wi-Fi.
  • The 12-bit ADC is good to about ±5% out of the box. Multi-sample averaging gets you to ±2%. For higher accuracy, use an external ADC.
  • analogSetPinAttenuation(pin, ADC_11db) is what you want for any sensor that outputs 0-3.3V.

When something breaks

  • Readings are 0 with Wi-Fi running. You are using ADC2 pins. Move to GPIO 32-39.
  • Readings max out at about 1100-1300. You forgot to set the attenuation. Add analogSetPinAttenuation.
  • Readings are noisy. Add the multi-sample averaging pattern above, or use a capacitor on the analog input (10-100 nF to GND).
  • Readings drift over temperature. The ESP32's internal reference voltage drifts with chip temperature. For sensor readings that need to be stable across temperature changes, use the external ADC.

What to build next

  • The BME280 sensor tutorial uses I2C, not analog, but the wiring patterns are similar and the project is the most common first sensor.
  • The deep sleep tutorial uses ADC readings as a trigger: only wake up the Wi-Fi radio when the sensor crosses a threshold.
  • The book Production IoT with ESP32 covers the ADS1115 external ADC in depth.

Chapter 13

ESP32: advertise as a BLE peripheral with the GATT server pattern

esp32 · 35 min

The ESP32 has Bluetooth Low Energy built in. You do not need a module add-on. You can make the ESP32 show up as a Bluetooth device that any phone or laptop can scan for and connect to.

This is the foundation for any BLE-based IoT project: a sensor that publishes data over BLE, a configurable device that a phone app can adjust, a remote control that triggers events. The ESP32 is the "peripheral" (the device exposing data), and the phone is the "central" (the device reading data).

This tutorial covers the GATT server pattern: services, characteristics, and notifications. The GATT client pattern is in the next tutorial.

What BLE is (in one paragraph)

BLE is a low-power version of Bluetooth designed for IoT. It works like this:

  • A peripheral advertises its presence. Phones can see it in the Bluetooth scan list.
  • A phone or laptop as the central connects to the peripheral.
  • Once connected, they exchange data through the GATT (Generic Attribute Profile), which is a tree of services and characteristics.

A service is a logical grouping (e.g. "Battery service"). A characteristic is a single data point (e.g. "battery level" is a single characteristic with a read value). The peripheral can also notify the central when a characteristic changes, which is how you stream live sensor data over BLE without polling.

What you need

  • ESP32 dev board
  • A phone or laptop with BLE (any phone made after 2015 has BLE)
  • The Arduino ESP32 board package, version 2.x or later

The code: a basic BLE peripheral

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

#define SERVICE_UUID        "4fafc201-1bc5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Starting BLE...");

  BLEDevice::init("ESP32-BLE-Example");
  BLEServer *server = BLEDevice::createServer();

  BLEService *service = server->createService(SERVICE_UUID);

  BLECharacteristic *characteristic = service->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);

  characteristic->setValue("Hello from ESP32!");
  characteristic->addDescriptor(new BLE2902());

  service->start();

  BLEAdvertising *advertising = BLEDevice::getAdvertising();
  advertising->addServiceUUID(SERVICE_UUID);
  advertising->setScanResponse(true);
  advertising->setMinPreferred(0x06);
  advertising->setMinPreferred(0x12);
  BLEDevice::startAdvertising();

  Serial.println("BLE peripheral ready. Scan with your phone.");
}

void loop() {
  delay(1000);
}

Upload. Open the Serial Monitor. Look at your phone's Bluetooth scan list. You should see "ESP32-BLE-Example." Connect with the nRF Connect app (free, available on iOS and Android). You can read the characteristic value ("Hello from ESP32!").

The service and characteristic UUIDs

The UUIDs 4fafc201-... and beb5483e-... are random. You generate your own. The convention is to use UUIDs that are unique to your project so your device does not collide with someone else's.

Use https://www.uuidgenerator.net or uuidgen on macOS/Linux. The standard BLE services (battery, heart rate, etc.) have assigned short UUIDs; you do not need those for custom data.

Adding a writable characteristic

Read-only is not enough for most projects. Add a writable characteristic for sending commands from the phone to the ESP32:

BLECharacteristic *commandChar = service->createCharacteristic(
  COMMAND_CHARACTERISTIC_UUID,
  BLECharacteristic::PROPERTY_WRITE);

commandChar->setCallbacks(new CommandCallback());

Where CommandCallback is a class that handles writes:

class CommandCallback : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *characteristic) {
    String value = characteristic->getValue();
    Serial.print("Received: ");
    Serial.println(value);
    if (value == "ON") {
      digitalWrite(LED_BUILTIN, HIGH);
    } else if (value == "OFF") {
      digitalWrite(LED_BUILTIN, LOW);
    }
  }
};

Now your phone app can write "ON" or "OFF" to the characteristic, and the ESP32 turns the LED on or off in response.

Notifying when a value changes

Read-once is fine for static data, but most sensor data changes over time. Notifications let the peripheral push updates to the central without being polled:

BLECharacteristic *sensorChar = service->createCharacteristic(
  SENSOR_CHARACTERISTIC_UUID,
  BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
sensorChar->addDescriptor(new BLE2902());

float lastReading = 0;

void loop() {
  float reading = analogRead(34) * 3.3 / 4095.0;   // some sensor
  if (reading != lastReading) {
    sensorChar->setValue(reading);
    sensorChar->notify();
    lastReading = reading;
  }
  delay(100);
}

The phone app subscribes to notifications on SENSOR_CHARACTERISTIC_UUID, and the ESP32 pushes updates whenever the value changes. This is how you stream live sensor data over BLE.

The BLE2902 descriptor is the standard "Client Characteristic Configuration Descriptor." It tells the phone app that this characteristic supports notifications. Without it, notifications do not work.

The disconnect and reconnect problem

If the phone moves out of range or the user disables Bluetooth, the connection drops. The peripheral needs to handle that and accept new connections:

class ServerCallback : public BLEServerCallbacks {
  void onConnect(BLEServer *server) {
    Serial.println("Connected");
  }

  void onDisconnect(BLEServer *server) {
    Serial.println("Disconnected");
    BLEDevice::startAdvertising();   // resume advertising
  }
};

void setup() {
  // ... after BLEDevice::createServer() ...
  server->setCallbacks(new ServerCallback());
}

Without startAdvertising() on disconnect, the ESP32 stops being discoverable after the first connection drops. Phone apps cannot find it again.

Power consumption

BLE on the ESP32 uses about 30 mA when actively connected. Compare to Wi-Fi at 80-200 mA. For battery-powered projects, BLE is the right call when the phone does not need to be online (most sensor monitoring apps).

For even lower power, the ESP32 can advertise and only wake up on connection. The deep sleep + BLE tutorial covers this in the book ESP32 Low Power.

What you learned

  • The ESP32 is a BLE peripheral out of the box. No module needed.
  • Services are containers, characteristics are the actual data points.
  • Read, write, and notify are the three operations a characteristic supports.
  • Notifications let you stream data without polling.

When something breaks

  • Phone does not see the ESP32. The phone BLE is off (check Settings), the ESP32 is not advertising (Serial Monitor should show "BLE peripheral ready"), or the advertisement is being filtered by the OS (rare on phones, common on some laptops).
  • Phone connects but cannot read. Wrong characteristic UUID in the app. Check the Serial Monitor output.
  • Notifications do not arrive. Missing BLE2902 descriptor on the characteristic. Or the phone app is not subscribed.
  • ESP32 stops being discoverable after one connection. Missing startAdvertising() on disconnect callback.

What to build next

  • The BLE central tutorial makes the ESP32 read from other BLE devices (heart rate straps, temperature sensors, beacons).
  • The ESP32 MQTT tutorial combines with this for a sensor that publishes over both BLE and Wi-Fi.
  • The book ESP32 IoT Projects covers custom BLE apps for iOS and Android using React Native.

Chapter 14

ESP32: PWM on every pin with the LEDC peripheral

esp32 · 30 min

The Arduino analogWrite() function does not work the way you think on the ESP32. On a real Arduino, it directly drives the timer hardware. On the ESP32, analogWrite() is a wrapper around the LEDC peripheral, and the default settings are wrong for most projects (8-bit resolution, only 16 channels, conflicts with other peripherals).

This tutorial covers LEDC the way I actually use it: pick a frequency, pick a resolution, attach a pin, set a duty cycle. By the end you will know how to drive a servo (50 Hz), dim an LED without flicker (5000 Hz), and control motor speed (25 kHz to stay above audible range).

What LEDC actually is

LEDC stands for "LED Control." It is a hardware peripheral on the ESP32 designed for generating PWM signals. The hardware does the toggling; your code just sets the duty cycle.

There are 16 LEDC channels, each independent. Each channel can be attached to one GPIO pin. So you can do PWM on up to 16 pins simultaneously, all without CPU involvement.

You pick two parameters per channel:

  • Frequency: how fast the PWM signal toggles, in Hz.
  • Resolution: how many steps in one duty cycle. 8-bit = 256 steps, 12-bit = 4096 steps.

These two are linked. At 50 Hz with 16-bit resolution, the timer ticks every 1/(50 * 65536) = 305 ns. That is the minimum pulse width you can control. For a servo that needs 1-2 ms pulse widths, 50 Hz at 16-bit resolution gives you about 1 us precision, which is more than enough.

The setup pattern

const int LED_PIN = 4;
const int PWM_FREQ = 5000;       // 5 kHz, good for LED dimming
const int PWM_RESOLUTION = 8;    // 8-bit, 0-255 duty
const int LEDC_CHANNEL = 0;

void setup() {
  ledcSetup(LEDC_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
  ledcAttachPin(LED_PIN, LEDC_CHANNEL);
}

void loop() {
  for (int duty = 0; duty <= 255; duty++) {
    ledcWrite(LEDC_CHANNEL, duty);
    delay(5);
  }
  for (int duty = 255; duty >= 0; duty--) {
    ledcWrite(LEDC_CHANNEL, duty);
    delay(5);
  }
}

Upload. The LED fades smoothly up and down.

Choosing frequency and resolution

The rule: pick the resolution first, then derive the frequency.

Resolution Steps Min pulse at 50 Hz
8-bit 256 78 us
10-bit 1024 20 us
12-bit 4096 4.9 us
16-bit 65536 305 ns

For different uses:

  • LED dimming: 5 kHz, 8-bit. Smooth to the eye, no flicker.
  • Servo control: 50 Hz, 16-bit. Standard servo protocol. 16-bit gives you ~305 ns precision; servos need ~1 us.
  • Motor speed control: 25 kHz or higher, 8-bit. Above audible frequency, so the motor does not whine. Resolution matters less because you are usually running at a fixed speed.
  • Audio output: 44.1 kHz or higher, 8-bit. You are replacing a DAC with PWM at audio frequencies.

ledcWrite() takes the duty cycle as an integer from 0 to the resolution max. For 8-bit, that is 0-255. For 16-bit, that is 0-65535. The Arduino-style analogWrite(pin, 128) calls map to 8-bit, so 128 is "half on" if the channel is 8-bit. If the channel is 16-bit, analogWrite(pin, 128) only gives you 128/65535 = 0.2% duty.

Why not just use analogWrite?

Two reasons.

First, analogWrite defaults to 8-bit resolution and a frequency that may not be appropriate. For servos you need 50 Hz; for LEDs you want 5 kHz. analogWrite does not let you set either.

Second, analogWrite and ledcWrite share the same underlying hardware. If you analogWrite(pin, 128) and then call ledcAttachPin to a different pin, the first pin's settings change. Mixing them is fragile.

The convention I follow: always use ledcSetup + ledcAttachPin + ledcWrite explicitly. Never use analogWrite on the ESP32. Saves debugging time later.

Driving a servo (50 Hz, 16-bit)

The servo signal is a 50 Hz PWM with pulse widths from 1 ms (0 degrees) to 2 ms (180 degrees), with 1.5 ms = 90 degrees. The period is 20 ms.

At 50 Hz and 16-bit resolution, the period is 1/50 = 20 ms, divided into 65536 steps. Each step is 20 ms / 65536 = 305 ns.

  • 1 ms = 1000000 ns / 305 ns = ~3280 steps
  • 1.5 ms = ~4920 steps
  • 2 ms = ~6550 steps
const int SERVO_PIN = 4;
const int SERVO_CHANNEL = 0;
const int SERVO_FREQ = 50;
const int SERVO_RES = 16;

const int SERVO_MIN = 3280;    // 0 degrees
const int SERVO_MAX = 6550;    // 180 degrees
const int SERVO_MID = 4920;    // 90 degrees

void setup() {
  ledcSetup(SERVO_CHANNEL, SERVO_FREQ, SERVO_RES);
  ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);
}

void loop() {
  ledcWrite(SERVO_CHANNEL, SERVO_MIN);
  delay(1000);
  ledcWrite(SERVO_CHANNEL, SERVO_MID);
  delay(1000);
  ledcWrite(SERVO_CHANNEL, SERVO_MAX);
  delay(1000);
}

Upload. The servo should sweep 0, 90, 180 degrees.

The min/max values depend on your specific servo. Cheap servos (SG90) need 1000-2000 us. Expensive digital servos often need 500-2500 us. Test and adjust. The pattern is the same.

Driving a motor at 25 kHz

DC motors like PWM above 20 kHz because it is above audible range. At 25 kHz, the motor does not whine.

const int MOTOR_PIN = 4;
const int MOTOR_CHANNEL = 0;
const int MOTOR_FREQ = 25000;   // 25 kHz, above audible
const int MOTOR_RES = 8;        // 8-bit is enough

void setup() {
  ledcSetup(MOTOR_CHANNEL, MOTOR_FREQ, MOTOR_RES);
  ledcAttachPin(MOTOR_PIN, MOTOR_CHANNEL);
}

void loop() {
  for (int speed = 0; speed <= 255; speed += 5) {
    ledcWrite(MOTOR_CHANNEL, speed);
    delay(100);
  }
  delay(1000);
  for (int speed = 255; speed >= 0; speed -= 5) {
    ledcWrite(MOTOR_CHANNEL, speed);
    delay(100);
  }
}

Add a motor driver (L298N, TB6612FNG, or similar) between the ESP32 and the motor. The ESP32 cannot drive a motor directly; its GPIO pins deliver at most 40 mA.

Using multiple channels

You can run PWM on 16 pins simultaneously. Each pin needs its own channel.

const int LED_PIN = 4;
const int SERVO_PIN = 5;
const int MOTOR_PIN = 6;

const int LED_CHANNEL = 0;
const int SERVO_CHANNEL = 1;
const int MOTOR_CHANNEL = 2;

void setup() {
  ledcSetup(LED_CHANNEL, 5000, 8);
  ledcAttachPin(LED_PIN, LED_CHANNEL);

  ledcSetup(SERVO_CHANNEL, 50, 16);
  ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);

  ledcSetup(MOTOR_CHANNEL, 25000, 8);
  ledcAttachPin(MOTOR_PIN, MOTOR_CHANNEL);
}

The hardware does the toggling. Your code just sets duty cycles. CPU usage is essentially zero.

Detaching a pin

If you want to use a pin for digital I/O after using it for PWM:

ledcDetachPin(LED_PIN);
pinMode(LED_PIN, OUTPUT);

The detach frees the LEDC channel for reuse on a different pin.

What you learned

  • LEDC is the ESP32's hardware PWM peripheral. 16 channels, each can drive one pin.
  • Pick frequency and resolution per channel. The two are linked.
  • Common combos: LED at 5 kHz / 8-bit, servo at 50 Hz / 16-bit, motor at 25 kHz / 8-bit.
  • Always use ledcSetup + ledcAttachPin + ledcWrite. Never use analogWrite on the ESP32.

When something breaks

  • The LED flickers. Frequency is too low. Bump to 5 kHz or higher.
  • The servo jitters. Frequency is right (50 Hz) but resolution is too low. Use 16-bit. Or the servo is underpowered (separate supply needed).
  • The motor whines. Frequency is below 20 kHz. Bump to 25 kHz.
  • ledcWrite does nothing. You forgot ledcAttachPin. Or you attached to a different pin than the one you wired.
  • analogWrite and ledcWrite conflict. Pick one. I use ledcWrite only.

What to build next

  • The servo motor tutorial combines this with the LEDC servo pattern for a knob-controlled servo.
  • The WS2812B tutorial uses LEDC at 800 kHz to drive NeoPixels. Yes, the hardware can do it.
  • The book ESP32 Robotics Projects covers motor control in depth (H-bridges, encoders, PID).

Chapter 15

ESP32: drive a hobby servo with LEDC

esp32 · 20 min

There are two ways to drive a hobby servo from an ESP32: the Servo.h library, or the LEDC peripheral directly. The library is easier for the first 10 minutes. LEDC is the right call for any project with more than one servo or anything that needs precise PWM control.

This tutorial covers the LEDC way. You learn what the servo signal actually is, how to generate it on any GPIO pin, and why LEDC is better than the Arduino library for projects that scale.

What a hobby servo expects

A hobby servo takes a 50 Hz PWM signal where the pulse width determines the angle:

  • 1.0 ms pulse: 0 degrees
  • 1.5 ms pulse: 90 degrees
  • 2.0 ms pulse: 180 degrees

The pulse repeats every 20 ms (50 Hz). The servo reads the pulse width and moves to the corresponding angle. The full pulse range varies by servo: cheap SG90s are 1000-2000 us, expensive digital servos are 500-2500 us. Check the datasheet for your specific servo.

The LEDC peripheral generates this PWM in hardware, so your code does not block. You set the duty cycle and the chip does the toggling. This is the right way to drive multiple servos at once, or any servo where you need to do other work in loop().

What you need

  • ESP32 dev board
  • Hobby servo (SG90 is the cheapest, MG996R is the standard pick for real torque)
  • 3 jumper wires
  • External 5V power supply if the servo needs more than 200 mA (most projects do)

Wiring

Servo red    -- 5V supply + (or ESP32 5V for SG90 only)
Servo brown  -- GND (both ESP32 GND and supply GND must be connected)
Servo orange -- ESP32 GPIO 4

The brown wire is ground. Connect the servo ground, the ESP32 ground, and the external supply ground together. Without this, the signal reference is floating and the servo will not move.

For an SG90 drawing under 200 mA, you can power it from the ESP32's 5V pin. For an MG996R or anything bigger, use a separate 5V supply rated for the servo current. The MG996R can draw 1.5 A stall.

The code

const int SERVO_PIN = 4;
const int SERVO_CHANNEL = 0;
const int SERVO_FREQ = 50;       // 50 Hz for servos
const int SERVO_RES = 16;        // 16-bit for fine pulse control

// For SG90 (1.0-2.0 ms range, default digital servo values)
const int SERVO_MIN = 3280;      // ~1.0 ms (0 degrees)
const int SERVO_MID = 4920;      // ~1.5 ms (90 degrees)
const int SERVO_MAX = 6560;      // ~2.0 ms (180 degrees)

void setup() {
  ledcSetup(SERVO_CHANNEL, SERVO_FREQ, SERVO_RES);
  ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);
}

void loop() {
  ledcWrite(SERVO_CHANNEL, SERVO_MIN);
  delay(1000);
  ledcWrite(SERVO_CHANNEL, SERVO_MID);
  delay(1000);
  ledcWrite(SERVO_CHANNEL, SERVO_MAX);
  delay(1000);
}

Upload. The servo should sweep 0, 90, 180 degrees.

Why those magic numbers

The 16-bit resolution at 50 Hz means the period (20 ms) is divided into 65536 steps. Each step is 20 ms / 65536 = 305 ns.

  • 1 ms = 1000 us = 1,000,000 ns. 1,000,000 / 305 = 3279. We round to 3280.
  • 1.5 ms = 1,500,000 / 305 = 4918. Round to 4920.
  • 2 ms = 2,000,000 / 305 = 6557. Round to 6560.

If your servo uses a different pulse range, adjust the constants:

// For a digital servo with 500-2500 us range (huge angle range)
const int SERVO_MIN = 1638;      // ~500 us
const int SERVO_MID = 4920;      // ~1500 us
const int SERVO_MAX = 8192;      // ~2500 us

Driving multiple servos

The LEDC peripheral has 16 channels. Each can drive one servo. Use a different channel per servo:

const int SERVO1_PIN = 4;
const int SERVO2_PIN = 5;
const int SERVO3_PIN = 6;

const int SERVO1_CHANNEL = 0;
const int SERVO2_CHANNEL = 1;
const int SERVO3_CHANNEL = 2;

const int SERVO_MIN = 3280;
const int SERVO_MID = 4920;
const int SERVO_MAX = 6560;

void setup() {
  ledcSetup(SERVO1_CHANNEL, 50, 16);
  ledcAttachPin(SERVO1_PIN, SERVO1_CHANNEL);

  ledcSetup(SERVO2_CHANNEL, 50, 16);
  ledcAttachPin(SERVO2_PIN, SERVO2_CHANNEL);

  ledcSetup(SERVO3_CHANNEL, 50, 16);
  ledcAttachPin(SERVO3_PIN, SERVO3_CHANNEL);
}

void loop() {
  // all three servos sweep in sync
  for (int angle = 0; angle <= 180; angle += 2) {
    int duty = map(angle, 0, 180, SERVO_MIN, SERVO_MAX);
    ledcWrite(SERVO1_CHANNEL, duty);
    ledcWrite(SERVO2_CHANNEL, duty);
    ledcWrite(SERVO3_CHANNEL, duty);
    delay(20);
  }
  // ...
}

This is the pattern for a 4-DOF robotic arm, a 6-DOF hexapod leg, or a pan-tilt camera mount. Up to 16 servos on one ESP32.

A knob-controlled servo (combines with the ADC tutorial)

const int SERVO_PIN = 4;
const int SERVO_CHANNEL = 0;
const int POT_PIN = 34;

void setup() {
  ledcSetup(SERVO_CHANNEL, 50, 16);
  ledcAttachPin(SERVO_PIN, SERVO_CHANNEL);
}

void loop() {
  int raw = analogRead(POT_PIN);
  int duty = map(raw, 0, 4095, 3280, 6560);
  ledcWrite(SERVO_CHANNEL, duty);
  delay(20);
}

Turn the knob, the servo follows. This is the foundation for "knob controls a motor" projects: robotic arm teach pendants, pan-tilt camera positioners, valve controllers.

Smooth motion with ramping

A servo that snaps from 0 to 180 in one step draws a lot of current and might overshoot. Smooth motion looks better and is easier on the mechanics:

int currentAngle = 90;
const int TARGET = 180;
const int STEP = 1;     // 1 degree per loop
const int DELAY = 15;   // ~67 fps

void loop() {
  if (currentAngle < TARGET) {
    currentAngle += STEP;
  } else if (currentAngle > TARGET) {
    currentAngle -= STEP;
  }
  int duty = map(currentAngle, 0, 180, 3280, 6560);
  ledcWrite(SERVO_CHANNEL, duty);
  delay(DELAY);
}

This is the basic motion profile. For real robotics, you want a trapezoidal velocity profile (accel, cruise, decel). That is in the book ESP32 Robotics Projects.

Why not use Servo.h

The Arduino Servo.h library works on the ESP32. It internally uses LEDC, but with these limitations:

  • It uses 8-bit resolution by default. The servo pulse width can vary by ~78 us, which is noticeable on cheap servos.
  • It does not let you pick the frequency. It picks 50 Hz, which is right for servos but inflexible.
  • Mixing Servo.h with other LEDC channels can cause conflicts.

For one servo in a simple project, Servo.h is fine. For multiple servos, LEDC direct is the right call.

What you learned

  • Servos expect a 50 Hz PWM with 1-2 ms pulse widths.
  • LEDC generates this on any GPIO pin, in hardware, with no CPU involvement.
  • 16-bit resolution at 50 Hz gives ~305 ns precision, more than enough for any servo.
  • Up to 16 servos on one ESP32, each on its own LEDC channel.

When something breaks

  • Servo jitters. Power issue. Check the supply voltage under load. Add a 100uF capacitor across the servo power pins.
  • Servo does not move at all. Wiring is wrong, or the duty cycle is at a position the servo cannot reach (e.g. outside its physical range). Test with SERVO_MID first.
  • Servo reaches one end and stalls. Pulse width is wrong for your servo. Adjust SERVO_MIN and SERVO_MAX per the datasheet.
  • Multiple servos on one supply reset the ESP32. Power supply is undersized. Use a 5V supply rated for the total servo current.

What to build next

  • The MPU6050 tutorial combines with this for a self-leveling camera gimbal: MPU6050 reads tilt, two servos correct.
  • The HC-SR04 tutorial adds obstacle sensing to a servo-driven robot.
  • The book ESP32 Robotics Projects covers multi-servo coordination and motion profiles.

Chapter 16

ESP32: drive a NEMA 17 stepper motor with the A4988 driver

esp32 · 30 min

The NEMA 17 stepper motor is the workhorse for any project that needs real torque and precise positioning: 3D printers, CNC machines, camera sliders, focus stacks. Pair it with the A4988 driver and an ESP32, and you have a motion control system that can lift a kilogram of payload while holding position with no jitter.

This tutorial covers the wiring, the basic stepping code, microstepping for smoother motion, and the current-limiting setup that keeps the A4988 from burning out.

What you need

  • ESP32 dev board
  • NEMA 17 stepper motor (the standard is 1.8 degree per step, 200 steps per revolution, but check the datasheet for your motor)
  • A4988 stepper driver carrier board (the Pololu A4988 is the original; many clones work fine but check the chip markings)
  • 100uF electrolytic capacitor (for across the motor power supply)
  • External 12V or 24V power supply rated for the stepper (NEMA 17s are typically 12V, 1.5 A per phase)

The ESP32's 3.3V GPIO cannot drive the A4988's 5V logic directly. Most A4988 boards have a 3.3V-compatible logic input (the Pololu board does), but check yours. If the board is 5V-only, add a level shifter.

Wiring

Power supply 12V+ --- A4988 VMOT
Power supply GND --- A4988 GND (also ESP32 GND)
A4988 1B          --- stepper coil 1 (one wire of the first coil)
A4988 1A          --- stepper coil 1 (the other wire of the first coil)
A4982 2A          --- stepper coil 2 (one wire of the second coil)
A4982 2B          --- stepper coil 2 (the other wire of the second coil)
A4988 VDD         --- ESP32 3.3V (logic power)
A4988 GND         --- ESP32 GND (same as above)
A4988 STEP        --- ESP32 GPIO 4
A4988 DIR         --- ESP32 GPIO 5
A4988 EN          --- ESP32 GPIO 16 (optional, active low to enable)
A4988 MS1         --- ESP32 GPIO 17 (optional, microstepping)
A4988 MS2         --- ESP32 GPIO 18 (optional, microstepping)
A4988 MS3         --- ESP32 GPIO 19 (optional, microstepping)

The stepper has two coils, four wires. The pair ordering matters; if you swap a pair, the motor will not spin smoothly. The datasheet for your specific motor tells you which colors are which pair. Most NEMA 17s use A+ (black or red), A- (green or yellow), B+ (blue), B- (white or yellow).

The 100uF capacitor across the motor power supply is not optional. The A4988 switches the motor coils at high frequency, and without bulk capacitance the supply voltage will spike, which can reset the ESP32 or damage the A4988.

Current limiting (the part that saves the motor)

The A4988 delivers up to 2 A per coil without a heatsink. Most NEMA 17s are rated for 1.5-1.7 A. Running the A4988 at full current will overheat the chip and the motor.

You set the current limit with the small potentiometer on the A4988 board. The formula:

V_ref = I_limit * 8 * R_sense

For the Pololu A4988 with the typical R_sense of 0.1 ohm:

Desired current V_ref
0.5 A 0.40 V
1.0 A 0.80 V
1.5 A 1.20 V
2.0 A 1.60 V

To set it: power the A4988 (motor power on, logic power on), connect a multimeter between the pot wiper and ground, and turn the pot until the voltage reads the value from the table. Use a non-conductive screwdriver (plastic or ceramic) so you do not short anything.

Test without a motor connected first. The A4988 will get hot at high currents; check it after a few minutes and add a heatsink if needed.

The code

const int STEP_PIN = 4;
const int DIR_PIN = 5;
const int STEPS_PER_REV = 200;   // 1.8 degree stepper

void setup() {
  pinMode(STEP_PIN, OUTPUT);
  pinMode(DIR_PIN, OUTPUT);
}

void stepOnce() {
  digitalWrite(STEP_PIN, HIGH);
  delayMicroseconds(10);   // A4988 needs at least 1 us pulse width
  digitalWrite(STEP_PIN, LOW);
  delayMicroseconds(1000);  // 1 ms between steps = 1000 steps/sec
}

void rotate(int steps) {
  if (steps < 0) {
    digitalWrite(DIR_PIN, LOW);
    steps = -steps;
  } else {
    digitalWrite(DIR_PIN, HIGH);
  }
  for (int i = 0; i < steps; i++) {
    stepOnce();
  }
}

void loop() {
  rotate(STEPS_PER_REV);   // one full turn clockwise
  delay(1000);
  rotate(-STEPS_PER_REV);  // one full turn counter-clockwise
  delay(1000);
}

Upload. The stepper should turn one full revolution, pause, turn the other way.

Microstepping for smooth motion

At full step (no microstepping), the motor moves in 1.8 degree jumps. You can hear and feel the discrete steps. Microstepping interpolates between full steps using PWM on the coil currents. The A4988 supports 1, 1/2, 1/4, 1/8, and 1/16 microstepping.

The MS1, MS2, MS3 pins select the mode:

MS1 MS2 MS3 Mode
LOW LOW LOW Full step
HIGH LOW LOW 1/2 step
LOW HIGH LOW 1/4 step
HIGH HIGH LOW 1/8 step
HIGH HIGH HIGH 1/16 step

For most projects, 1/8 or 1/16 microstepping gives smooth motion at the cost of some torque (microstepping reduces torque by 10-30% per step because both coils are partially energized).

const int MS1_PIN = 17;
const int MS2_PIN = 18;
const int MS3_PIN = 19;

void setup() {
  pinMode(MS1_PIN, OUTPUT);
  pinMode(MS2_PIN, OUTPUT);
  pinMode(MS3_PIN, OUTPUT);

  // 1/8 microstepping
  digitalWrite(MS1_PIN, HIGH);
  digitalWrite(MS2_PIN, HIGH);
  digitalWrite(MS3_PIN, LOW);
}

void rotate(int steps) {
  // Now steps is in 1/8 microsteps, so 1600 per revolution
  // ...
}

Acceleration and deceleration

A stepper that snaps to full speed and then to zero position will miss steps. Add a trapezoidal velocity profile:

void rotateSmooth(int totalSteps, int maxSpeedStepsPerSec) {
  const int accelSteps = 50;   // ramp over 50 steps

  if (totalSteps < 0) {
    digitalWrite(DIR_PIN, LOW);
    totalSteps = -totalSteps;
  } else {
    digitalWrite(DIR_PIN, HIGH);
  }

  for (int i = 0; i < totalSteps; i++) {
    int speed;
    if (i < accelSteps) {
      speed = maxSpeedStepsPerSec * (i + 1) / accelSteps;
    } else if (i > totalSteps - accelSteps) {
      speed = maxSpeedStepsPerSec * (totalSteps - i) / accelSteps;
    } else {
      speed = maxSpeedStepsPerSec;
    }

    if (speed == 0) speed = 1;
    int delayMicros = 1000000 / speed;
    digitalWrite(STEP_PIN, HIGH);
    delayMicroseconds(10);
    digitalWrite(STEP_PIN, LOW);
    delayMicroseconds(delayMicros);
  }
}

This is the basic profile. For real CNC work, use the AccelStepper library on the ESP32 (it has been ported). The library handles acceleration, deceleration, and coordinated multi-motor motion.

The enable pin

The A4988 has an EN (enable) pin. Tie it LOW to enable the driver, HIGH to disable (the motor will freewheel). Most projects tie it to ground permanently. If you want to put the driver in low-power mode between moves, control it from a GPIO:

const int EN_PIN = 16;

void setup() {
  pinMode(EN_PIN, OUTPUT);
  digitalWrite(EN_PIN, LOW);   // enabled
}

void disableMotor() {
  digitalWrite(EN_PIN, HIGH);   // freewheel
}

void enableMotor() {
  digitalWrite(EN_PIN, LOW);    // hold position
}

Disabling between moves saves power but means the motor will not hold position. Enable it again before resuming motion.

What you learned

  • NEMA 17 + A4988 is the standard stepper combo for ESP32 motion control.
  • The A4988 takes a STEP pulse (one pulse = one step) and a DIR signal (high or low for direction).
  • Microstepping smooths motion at the cost of torque.
  • Current limiting via the pot is critical to motor life.

When something breaks

  • Motor vibrates but does not spin. Coil pairs are wrong. Swap one pair.
  • Motor stalls under load. Current limit is too low. Adjust the pot. Or microstepping is too aggressive (1/16 has the least torque).
  • A4988 gets very hot. Current limit is too high. Adjust the pot down. Or add a heatsink.
  • ESP32 resets when the motor moves. Power supply is undersized or capacitor is missing. The 100uF cap is mandatory.

What to build next

  • The servo tutorial is the alternative for low-torque, low-speed positioning. Combine both for projects with mixed motion needs.
  • The book ESP32 Robotics Projects covers multi-axis stepper coordination, limit switches, and homing routines.
  • The book ESP32 CNC covers GRBL porting to the ESP32 for real CNC control.

Chapter 17

ESP32: connect to BLE peripherals with the GATT client pattern

esp32 · 35 min

The ESP32 is not just a BLE peripheral. It can also be a BLE central: scan for nearby BLE devices, connect to them, and read their data. This turns the ESP32 into a sensor hub that reads heart rate straps, temperature beacons, fitness trackers, and other BLE peripherals.

This is the reverse of the BLE peripheral tutorial. The ESP32 here is the client; the BLE device you are reading is the server.

This tutorial covers scanning, connecting, discovering services, and reading characteristics. Most of what you do with a BLE central is follow the standard service UUIDs (heart rate, battery, environment sensing) and read the standard characteristics.

What you need

  • ESP32 dev board
  • A BLE peripheral device (your phone with the nRF Connect app works great for testing; a heart rate strap, BLE temperature sensor, or another ESP32 also works)
  • Arduino ESP32 board package 2.x or later

The code: scan for nearby devices

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

class ScanCallback : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    Serial.print("Found: ");
    Serial.print(advertisedDevice.getName().c_str());
    Serial.print("  RSSI: ");
    Serial.print(advertisedDevice.getRSSI());
    Serial.print("  Address: ");
    Serial.println(advertisedDevice.getAddress().toString().c_str());
  }
};

void setup() {
  Serial.begin(115200);
  delay(1000);

  BLEDevice::init("");
  BLEScan *scanner = BLEDevice::getScan();
  scanner->setAdvertisedDeviceCallbacks(new ScanCallback());
  scanner->setActiveScan(true);
  scanner->setInterval(100);
  scanner->setWindow(99);
}

void loop() {
  BLEScanResults results = *BLEDevice::getScan()->start(5, false);
  Serial.print("Found ");
  Serial.print(results.getCount());
  Serial.println(" devices");
  BLEDevice::getScan()->clearResults();
  delay(5000);
}

Upload. Open Serial Monitor. You should see a list of BLE devices every 5 seconds, including their RSSI (signal strength, higher is closer) and MAC address.

RSSI is a quick way to estimate distance. -30 dBm is right next to the ESP32. -90 dBm is at the edge of range. BLE has about 50 m line of sight, less through walls.

Connecting to a specific device

Once you find a device you want to connect to, filter by name or address, then open a connection:

class ConnectCallback : public BLEAdvertisedDeviceCallbacks {
  bool doConnect = false;
  BLEAdvertisedDevice *target = nullptr;

  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveName() &&
        advertisedDevice.getName() == "ESP32-BLE-Example") {
      advertisedDevice.getScan()->stop();
      target = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
    }
  }

  bool shouldConnect() { return doConnect; }
  BLEAdvertisedDevice* getTarget() { return target; }
};

Then in loop():

void loop() {
  if (connectCallback.shouldConnect()) {
    connectToServer(connectCallback.getTarget());
    connectCallback.clear();
  }
  // ...
}

void connectToServer(BLEAdvertisedDevice *device) {
  BLEClient *client = BLEDevice::createClient();
  client->connect(device);
  Serial.print("Connected to ");
  Serial.println(device->getName().c_str());

  BLERemoteService *service = client->getService(SERVICE_UUID);
  if (service == nullptr) {
    Serial.println("Service not found");
    client->disconnect();
    return;
  }

  BLERemoteCharacteristic *characteristic =
    service->getCharacteristic(CHARACTERISTIC_UUID);
  if (characteristic == nullptr) {
    Serial.println("Characteristic not found");
    client->disconnect();
    return;
  }

  if (characteristic->canRead()) {
    String value = characteristic->readValue();
    Serial.print("Read: ");
    Serial.println(value);
  }

  client->disconnect();
}

This connects, finds the service and characteristic by UUID, reads the value, then disconnects. Most BLE clients work in this pattern: connect, read what you need, disconnect.

Subscribing to notifications

If you want continuous updates (e.g. heart rate every second), subscribe to notifications instead of polling:

if (characteristic->canNotify()) {
  characteristic->registerForNotify([](BLERemoteCharacteristic *c, uint8_t *data,
                                       size_t length, bool isNotify) {
    Serial.print("Notify: ");
    for (size_t i = 0; i < length; i++) {
      Serial.print((char)data[i]);
    }
    Serial.println();
  });
}

The callback fires every time the peripheral sends a notification. This is the right pattern for streaming sensor data.

Standard service UUIDs

Most consumer BLE devices implement one or more standard services. The Bluetooth SIG assigns short 16-bit UUIDs for these:

Service UUID What you read
Battery 0x180F Battery level (0-100%)
Heart Rate 0x180D Heart rate (BPM)
Environment Sensing 0x181A Temperature, humidity, pressure
Health Thermometer 0x1809 Body temperature
Cycling Power 0x1818 Power output in watts
Running Speed and Cadence 0x1814 Pace and stride

For example, to read a heart rate strap:

BLERemoteService *hrService = client->getService(BLEUUID((uint16_t)0x180D));
BLERemoteCharacteristic *hrChar = hrService->getCharacteristic(BLEUUID((uint16_t)0x2A37));
if (hrChar->canNotify()) {
  hrChar->registerForNotify([](BLERemoteCharacteristic *c, uint8_t *data,
                                 size_t length, bool isNotify) {
    // Heart Rate Measurement format: byte 0 = flags, byte 1+ = HR value
    uint8_t hr = data[1];
    Serial.print("Heart rate: ");
    Serial.println(hr);
  });
}

The standard services are documented at https://www.bluetooth.com/specifications/specs/.

BLE scanning is power-hungry

Active scanning (the kind that gets device names) takes about 30 mA on the ESP32. For battery-powered projects, scan briefly and then sleep:

void loop() {
  BLEScanResults results = *BLEDevice::getScan()->start(2, false);
  // process results
  BLEDevice::getScan()->clearResults();

  esp_deep_sleep_start();   // sleep until next wake
}

This pattern works for projects that wake every minute to scan, take a reading, and go back to sleep. Battery life is measured in months.

The "device disappeared" problem

BLE devices are flaky. They go out of range, run out of battery, or disconnect without warning. Your client code needs to handle the disconnect gracefully:

class MyClientCallback : public BLEClientCallbacks {
  void onDisconnect(BLEClient *client) {
    Serial.println("Disconnected, will retry");
    doConnect = true;   // flag for the next loop iteration
  }
};

client->setCallbacks(new MyClientCallback());

Set a flag and retry on the next loop. Without this, a flaky device permanently breaks your project.

What you learned

  • The ESP32 can be a BLE central, scanning for and connecting to other BLE devices.
  • Scanning is power-hungry. Brief scans + deep sleep is the right pattern for battery projects.
  • Most consumer BLE devices implement standard services with 16-bit UUIDs. Learn those and you can read any heart rate strap, fitness tracker, or temperature beacon.
  • Notifications are how you stream data, not polling.

When something breaks

  • Scan finds nothing. No BLE devices nearby, or the antenna is covered (the ESP32's antenna is on the PCB near the top edge).
  • Connect succeeds but service not found. Wrong UUID. Or the peripheral has not finished advertising the service (try again after a few seconds).
  • Read returns empty. The peripheral is waiting for an encryption key, or the characteristic requires a specific read protocol.
  • Notifications do not fire. You registered for notify after the peripheral already started sending them. Disconnect, reconnect, then register.

What to build next

  • The BLE peripheral tutorial is the other half. Build a sensor that publishes to your phone, then write a client to read it back.
  • The ESP32 MQTT tutorial combines BLE readings with MQTT publishing for a sensor network.
  • The book ESP32 IoT Projects covers real BLE applications: indoor positioning using BLE beacons, fitness tracking, BLE-based mesh networks.

Chapter 18

ESP32: send HTTPS requests with root CA validation

esp32 · 30 min

The ESP32 can talk to HTTPS APIs the same way your laptop does, but the default Arduino HTTP client does not validate TLS certificates. That is fine for testing. It is also how you ship data to the wrong server if someone MITMs your network.

This tutorial covers the right way: load the root CA certificate, validate the chain, and send a real HTTPS request. By the end you can talk to anything that has an HTTPS endpoint, with the security guarantees your laptop gets by default.

What you need

  • ESP32 dev board
  • A Wi-Fi network
  • A web service to talk to. For testing, use <httpbin.org> or a public weather API.

The library

The Arduino HTTPClient library works for HTTPS, but you need to pass it the root CA certificate. For the modern ESP32 Arduino core, the WiFiClientSecure class handles TLS.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

Finding the root CA

The "root CA" is the certificate at the top of the chain that signed the server's certificate. Your laptop trusts hundreds of root CAs because they come with the OS. The ESP32 has no such trust store.

For a specific server, you can extract the root CA. Visit the site in your browser, click the lock icon, view the certificate, and copy the "Issuer" or "Root CA" certificate. Save it as a PEM file.

For <httpbin.org>, the root CA is "ISRG Root X1" (Let's Encrypt). For most public APIs, the same root CA applies.

The certificate in code

The root CA goes in your sketch as a string constant. The PEM file looks like this:

-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHTha2jSi08zmYjLEzG8hxB9Z8RNlvESvCY3jDFcPlLBzCqKVSjnl2yfNZ9j8+lkfDyLvAhWWpdvk9+ipLZ4R3H8B5z8G8nOMN1Wjj4lEd1ZAC3gpF1Z5l4bffTX+Vw4nBvdKj1zlHI8W9JWAaRFWQybd3uFi3dINl8QG5g8uGFsL8fTQG0d4cPgrJptliTVjpaB0BxLx3L2VPVj7V9Y5DlcGzumckAVyU4XbB+IfSOvk2c0i2tOcX8GQOELcG0aXfXMxPnoC33fOzayVjd39L8J+JP9DwQ4oehb4Meo5gXrpmt0TbOladTdU0CAwEAAaOCAW8wggFrMB8GA1UdIwQYMBaAFFrQC4H1LP7CGnWt1b4o8AKi//n3MC4GA1UdEQQnMCSCDnRtcC5ocmVzdGFwaS5nby5wb3AubmV0ggxocmVzdGFwaS5nbzAdBgNVHQ4EFgQUFrQC4H1LP7CGnWt1b4o8AKi//n3MMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABnTDPEF+3iSP0hNrLDhZx2mnqypJ5YIrO4dZ2EqK4n8U7OE8JbV6MSAgwqavTQ2VNjtVpsBY5q+8uC1A1xLqnJF1y7Ct4CYRZCiH2yO9PE3y8jVBPwFF1bk2WpHvwG9N30EbCeqgxqqFJ1Kt9LkP/6aJTn2tcLR2K9h4d//QAlzJBuY7dHBQ11l5THlpimCv7trSQR4iHhfq6MU8U+wVMKGOAyXXJLxNe2BC0MWhqQGA1bjXJ5A5SrHF36lZePzPZke5nOFOpV/0nNw4ypB9oyhatESdttRNp12NiV4VAK3fW1QUu7HekmTIRuZzU2BdMhp9L8VRYuJSaPZ8d+mRy4c8fJ7VBY5uvT8nA0CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMB8GA1UdIwQYMBaAFI0cxb6VTEM8YYY6FbBMvAPyT+CyMA8GA1UdEwEB/wQFMAMBAf8wggE6BgNVHSAEggE6MIIBNjAYBg1UdEQQwMC6CDnRtcC5ocmVzdGFwaS5nbzAJBgVngQwBAgMwgfQGCyqGSIb3DQEJEAIBBIGWCmCGaEExBwQFBgc=
-----END CERTIFICATE-----

Save this as a C string in your sketch.

The code: HTTPS GET

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* ssid = "your-wifi-ssid";
const char* password = "your-wifi-password";

// httpbin.org root CA (Let's Encrypt ISRG Root X1)
const char* rootCA = \
"-----BEGIN CERTIFICATE-----\n" \
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw" \
... (full cert as above) ...
"-----END CERTIFICATE-----\n";

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("Connected to Wi-Fi");

  WiFiClientSecure *client = new WiFiClientSecure();
  client->setCACert(rootCA);

  HTTPClient http;
  http.begin(*client, "https://httpbin.org/get");
  int httpCode = http.GET();

  if (httpCode > 0) {
    Serial.print("HTTP ");
    Serial.println(httpCode);
    Serial.println(http.getString());
  } else {
    Serial.print("Error: ");
    Serial.println(http.errorToString(httpCode).c_str());
  }

  http.end();
}

void loop() {
}

Upload. Open Serial Monitor. You should see:

Connected to Wi-Fi
HTTP 200
{
  "args": {},
  "headers": {
    "Accept": "*/*",
    ...
  },
  "origin": "192.168.1.42",
  "url": "https://httpbin.org/get"
}

If you see "connection refused" or a TLS error, the root CA is wrong or out of date. Certificates expire every few years; you may need to refresh the PEM.

The insecure option (do not ship this)

For development, you can disable TLS validation entirely:

client->setInsecure();   // <-- skips all certificate validation

The connection is still encrypted, but you have no guarantee that you are talking to the server you think you are. Use this for prototyping only. Ship with setCACert(rootCA) so a man-in-the-middle cannot intercept your data.

Posting JSON to an API

For sending sensor data, you POST a JSON body:

http.begin(*client, "https://api.example.com/sensors/esp32-1");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", "Bearer your-token-here");

String payload = "{\"temperature\":22.5,\"humidity\":45.2}";
int httpCode = http.POST(payload);

if (httpCode == 200 || httpCode == 201) {
  Serial.println("Posted successfully");
} else {
  Serial.print("Failed: ");
  Serial.println(httpCode);
}

Most cloud APIs (AWS IoT, Google Cloud IoT, Adafruit IO, custom REST APIs) accept JSON over HTTPS. The pattern is the same for all of them.

Memory considerations

Each HTTPS connection uses about 30 KB of RAM for the TLS state. The ESP32 has about 320 KB total. If you have many simultaneous connections or large JSON payloads, watch the memory:

Serial.print("Free heap: ");
Serial.println(ESP.getFreeHeap());

If free heap drops below 30 KB, you are about to crash. Strategies:

  • Make HTTPS requests sequentially, not in parallel.
  • Stream large responses to a buffer you control.
  • Use HTTP/1.1 with Connection: close so the connection is torn down after each request.

When the API requires a client certificate

Some APIs (mostly enterprise or B2B) require mutual TLS: the server verifies the client's certificate too. The ESP32 can do this:

client->setCACert(rootCA);
client->setCertificate(clientCert);
client->setPrivateKey(clientKey);

The client certificate and private key are PEM-encoded. You generate them on the server side (or via OpenSSL on your laptop) and embed them in the firmware. For device fleets, you sign the client cert with your own CA so you can revoke it.

What you learned

  • WiFiClientSecure handles TLS on the ESP32.
  • Pass the root CA certificate via setCACert() for proper validation.
  • Use setInsecure() for prototyping only. Ship with setCACert().
  • JSON POST is the standard pattern for sending sensor data to cloud APIs.

When something breaks

  • TLS handshake fails. Wrong or expired root CA. Update the PEM.
  • HTTP error 401. Wrong or missing API token. Check the auth header.
  • HTTP error 403. Server is reachable but your request is rejected. Check the API docs for required parameters.
  • Connection times out. Server is slow or unreachable. Add a longer timeout: http.setTimeout(10000) for 10 seconds.

What to build next

  • The ESP32 MQTT tutorial is the alternative to HTTPS for IoT. MQTT is lighter on bandwidth and battery.
  • The book Production IoT with ESP32 covers AWS IoT, Google Cloud IoT, and Azure IoT Hub in depth.
  • The HTTPS with root CA rotation tutorial (planned) covers what happens when your CA expires every few years and how to ship updates.

Chapter 19

ESP32: wire a 18650 battery with a TP4056 charge controller

esp32 · 20 min

The single most useful battery for ESP32 projects is the 18650 cell. It is the same battery used in laptops, vape pens, and Tesla Powerwalls (scaled up). It has high energy density, is rated for hundreds of charge cycles, and is available for $3-5 per cell from reputable sources.

Pair it with the TP4056 charge controller and you have a battery system that charges over USB, has built-in over-discharge protection, and powers the ESP32 at 3.3V via a regulator. This tutorial is the minimum circuit.

What you need

  • One 18650 cell (use a genuine Samsung, LG, Panasonic, or Sony cell. Counterfeit 18650s are everywhere and can be dangerous.)
  • TP4056 charge controller board (the kind with battery protection built in; about $1 from anywhere)
  • 3.7V to 3.3V LDO regulator (the ME6211 or HT7333 are the standard picks. The AMS1117-3.3 also works but has higher quiescent current.)
  • One 100uF electrolytic capacitor
  • One 10uF electrolytic capacitor
  • Wires, soldering iron, basic tools

"TP4056 with protection" is the version you want. The bare TP4056 chip does not include the over-discharge protection circuit. The modules with "DW01" or "8205A" markings on them have it. Modules without those markings do not.

Wiring

18650 + --- TP4056 B+
18650 - --- TP4056 B-
USB 5V  --- TP4056 IN+   (or USB-C connector's VBUS pin)
USB GND --- TP4056 IN-   (or USB-C connector's GND pin)
TP4056 OUT+ --- LDO IN   (3.7-4.2V from the battery)
TP4056 OUT- --- LDO GND
LDO OUT (3.3V) --- ESP32 3.3V
LDO GND        --- ESP32 GND

The TP4056 charges the 18650 from any 5V source (USB). It outputs the battery's voltage (3.0-4.2V depending on charge state) on OUT+. The LDO regulator drops that to 3.3V for the ESP32.

Important: do not power the ESP32 from the TP4056's OUT+ pin directly. The battery voltage is 4.2V when fully charged, which exceeds the ESP32's 3.3V maximum. The LDO regulator is mandatory.

What the TP4056 actually does

The TP4056 is a single-chip linear charger for single-cell lithium-ion batteries. It charges at a configurable rate (typically 1A, set by the R3 resistor on the module) using the CC/CV profile:

  1. Constant current (CC) at 1A until the battery reaches 4.2V
  2. Constant voltage (CV) at 4.2V until the charge current drops below 10% of the set rate

This is the standard lithium-ion charging profile. Doing it wrong (over-voltage, over-current, fast charge at high temperature) damages the cell and can cause fire. The TP4056 handles all of that.

The TP4056 board also includes the DW01 protection chip and a dual MOSFET (8205A). These provide:

  • Over-discharge protection. Disconnects the battery from the load when voltage drops below about 2.5V. Prevents the cell from being damaged by deep discharge.
  • Over-charge protection. Disconnects the charger when voltage exceeds 4.3V.
  • Short circuit protection. Disconnects the load on a short.
  • Over-current protection. Disconnects the load above about 3A.

These protections are why you want the version with the protection circuit, not the bare TP4056 chip.

Reading the battery voltage

The ESP32 can measure its own supply voltage using the ADC's internal hall-sensor channel as a proxy, but the cleaner way is to read the battery voltage through a voltage divider:

const int BATT_PIN = 34;
const float R1 = 100000.0;   // 100k
const float R2 = 100000.0;   // 100k (gives 50% divider)

float readBatteryVoltage() {
  int raw = analogRead(BATT_PIN);
  float adcVoltage = raw * 3.3 / 4095.0;
  return adcVoltage * (R1 + R2) / R2;
}

Wire the divider between the TP4056 OUT+ pin and ESP32 GND, with the middle node on GPIO 34. With R1 = R2 = 100k, the divider halves the voltage, so the formula above is correct for a 1:1 divider.

The ESP32 draws about 10 uA through the divider continuously. That is small enough not to matter for most projects. For ultra-low-power projects, use a 1M + 1M divider (1 uA) and account for the ADC's internal resistance.

Battery life math

A genuine 18650 cell has about 2500-3500 mAh capacity at 3.7V nominal. Convert to watt-hours:

Wh = Ah * V = 2.5 Ah * 3.7 V = 9.25 Wh

The ESP32 draws about 30 mA active (with Wi-Fi), or about 0.1 mA in deep sleep with periodic wake-up. If you sleep 99% of the time and wake briefly every minute:

Average current = 0.99 * 0.1 mA + 0.01 * 30 mA = 0.4 mA
Battery life = 2500 mAh / 0.4 mA = 6250 hours = 260 days

That assumes a single wake per minute. For projects that wake more often (e.g. every second), the active current dominates and the battery lasts about a week.

The charging current

The TP4056 charges at a current set by the R3 resistor on the module:

R3 value Charge current
1.2 kohm 1 A (default on most modules)
2.0 kohm 580 mA
3.0 kohm 400 mA
5.0 kohm 250 mA
10 kohm 130 mA
20 kohm 50 mA

For most 18650 cells, 0.5-1 A is the standard charge rate. Charging faster than the cell's spec (usually 0.5C, where C is the capacity) damages the cell. For a 2500 mAh cell, max safe charge current is 1250 mA.

The 100uF capacitor

The TP4056's output has a small amount of switching noise. The 100uF capacitor across OUT+ and OUT- smooths this for the ESP32. Without it, the ESP32 may brown-out during Wi-Fi activity.

What you learned

  • TP4056 with the DW01 protection chip is the standard charger for 18650 cells in ESP32 projects.
  • LDO regulator (3.7V to 3.3V) is mandatory; the ESP32 cannot handle the 4.2V fully-charged battery voltage.
  • Voltage divider on GPIO 34 lets you read battery voltage through the ADC.
  • Realistic battery life is months to a year for typical sensor projects.

When something breaks

  • Battery reads 0V or the ESP32 does not power up. Battery is dead or the protection circuit tripped. Plug in USB; the TP4056 will charge the cell back up to the protection threshold.
  • ESP32 resets when Wi-Fi connects. Bulk capacitor too small or missing. Add 100uF across the LDO output.
  • Battery gets hot during charging. Counterfeit cell. Genuine cells do not get more than slightly warm during 1A charging.
  • TP4056 LED does not light. No USB power, or bad USB cable (charge-only, not data and power).

What to build next

  • The deep sleep tutorial shows the code patterns for the 99%-asleep math above.
  • The solar + 18650 tutorial covers adding a small solar panel for perpetual battery projects.
  • The book ESP32 in Production covers battery certification (UN38.3) for shipping products with lithium batteries.

Chapter 20

ESP32: dev boards vs bare modules, the right pick for battery projects

esp32 · 15 min

The dev board with the USB connector, the voltage regulator, and all the breakout pins costs about $5 and takes 5 minutes to get running. The bare ESP32-WROOM-32 module costs about $2 and takes half a day to solder onto a custom board. For your first 20 projects, use the dev board. For your project 21 (the one that has to fit in a tiny case and run for 6 months on a battery), drop down to the bare module.

This tutorial covers when the swap makes sense, what you give up, and what you gain.

What the dev board gives you

The standard ESP32 dev board (the ESP32-DevKitC or NodeMCU-32S) bundles:

  • The ESP32-WROOM-32 module (the actual chip + antenna + flash)
  • A USB-to-serial chip (CP2102 or CH340)
  • A voltage regulator (3.3V from the USB's 5V)
  • A reset button and a boot button
  • Two rows of breakout pins (0.1 inch pitch, breadboard-compatible)
  • An onboard LED on GPIO 2

For development, that is everything you need. Plug in USB, press the boot button if needed, upload a sketch, wire up some sensors, see results. The dev board is the right call for every prototype.

What the bare module gives you

The ESP32-WROOM-32 module by itself is just the chip, the antenna, the flash, and castellated pads (the little half-holes on the edge that you can solder to a custom PCB). No USB, no buttons, no regulator.

To use it, you design a PCB that includes:

  • A 3.3V regulator (or run straight from a battery in some cases)
  • A USB-to-serial chip if you want to program it
  • Reset and boot buttons or test pads
  • Pin headers or screw terminals for your sensors
  • Your application's components

The PCB design is half a day of work for someone who has done it before and a week for someone who has not. Tools: KiCad (free), EasyEDA (free, web-based), or Altium Designer (not free, but the standard for real products).

When to make the swap

Stay on the dev board when:

  • You are still iterating on the software. Pulling a dev board out of a project box to reprogram it is faster than wiring up a USB cable to a custom PCB.
  • You only need one of them. The dev board is $5, and PCB fabrication costs $20-50 minimum. For a single prototype, the math is not there.
  • The dev board's size fits your enclosure. A dev board is roughly 55mm x 28mm. If your enclosure is bigger than that, do not bother designing a custom board.
  • You are selling fewer than 50 units. The dev board cost difference is real but not transformative at low volume.

Move to the bare module when:

  • The dev board does not fit your enclosure. Common for wearables, in-wall sensors, and small robots.
  • You need to drop the current consumption below what the dev board's components allow. The onboard USB-serial chip draws about 10 mA even when idle. The onboard voltage regulator drops 1-2 mA. A bare module plus a low-dropout regulator can hit 10 uA in deep sleep.
  • You need reliability. The dev board's pin headers are the most common failure point in production. A soldered module survives vibration, thermal cycling, and physical handling.
  • You are selling more than 100 units. At that volume, the per-unit cost savings on components and assembly pay for the design time.

The bare module's power advantage

This is the biggest reason to switch. The dev board's regulators and USB-serial chip burn power even when the ESP32 is asleep:

Stage Dev board current Bare module current
Active (Wi-Fi TX) 80-200 mA 80-200 mA (same)
Active (CPU only) 30-50 mA 30-50 mA (same)
Modem sleep 20-30 mA 5-15 mA
Light sleep 5-10 mA 0.8-1.5 mA
Deep sleep 0.15-10 mA 0.01-0.15 mA

The deep sleep number is the one that matters for battery life. A dev board at 0.15 mA in deep sleep will drain a 2000 mAh battery in about 1.5 years (assuming the ESP32 wakes briefly every hour to do its job). A bare module at 0.01 mA will last 22 years on the same battery. The math changes for the better when you sleep most of the time.

The actual deep sleep current depends on which peripherals you leave enabled. ULP coprocessor running, RTC memory retained, touch pins powered. The book ESP32 Low Power covers all of this.

The size advantage

The dev board is about 28mm x 55mm x 14mm (the height includes the USB connector). The ESP32-WROOM-32 module alone is 18mm x 26mm x 3mm. That is a 4x area reduction and 5x volume reduction. For projects that need to fit somewhere small, the bare module is the only option.

What you lose

The bare module tradeoffs:

  • No USB. You need a USB-to-serial chip on your PCB, or you need to buy an FTDI cable or similar to program it.
  • No onboard buttons. You need to add reset and boot buttons or test pads, or you can live with the manual reset procedure (hold GPIO 0 low, pulse EN).
  • No 5V input. The bare module is 3.3V only. If your project needs to run from a higher voltage battery or a USB input, you need a regulator on your PCB.
  • No breakout pins. You either solder headers, design castellated edge pads into your PCB, or use surface-mount soldering.

The first three are the ones people forget about. The fourth is a soldering skill issue.

The middle path: ESP32 modules with built-in USB

The ESP32-S3 and ESP32-C3 have a USB-OTG peripheral built into the chip. Modules based on these (ESP32-S3-DevKitC, ESP32-C3-DevKitM) can be programmed directly over USB without an external USB-serial chip. They are the right call when:

  • You want the bare-module form factor with USB for programming
  • You do not mind the slightly higher cost ($3-5 per module)
  • The slightly higher active current is OK for your battery budget

The ESP32-S3 is the standard pick for new battery projects in 2026.

What to build next

  • The 18650 + TP4056 tutorial covers the battery-side wiring for either a dev board or a bare module.
  • The deep sleep tutorial shows the code patterns for getting the deep sleep current down to the bare-module minimum.
  • The book ESP32 in Production covers PCB layout, antenna design, and the regulatory stuff (FCC, CE) you need to ship a bare-module product.

When you should definitely stay on the dev board

If you are reading this and trying to decide for a hobby project, stay on the dev board. The bare module path is for when the constraints force it. Save the custom PCB for the project that has outgrown the breadboard.


Chapter 21

ESP32: read an MQ-2 gas sensor (the honest reading)

esp32 · 20 min

The MQ-2 is the cheap gas sensor for detecting LPG, propane, methane, alcohol, and smoke. It is a $2 sensor that outputs an analog voltage proportional to gas concentration. It is also the sensor with the most misleading documentation on the internet. This tutorial is the honest version: what it can detect, what it cannot, and how to interpret the readings.

The single most important thing to know about MQ sensors: they cannot distinguish between gases. The MQ-2 responds to LPG, propane, methane, hydrogen, alcohol, and smoke all at once. The analog reading tells you "something combustible is present" but not what.

What you need

  • ESP32 dev board
  • MQ-2 sensor module (the breakout board with the comparator; about $2)
  • 5 jumper wires

Wiring

The MQ-2 module has 4 pins: VCC, GND, DO (digital out), AO (analog out).

MQ-2 VCC -- ESP32 5V (the sensor heater needs 5V; 3.3V may not work)
MQ-2 GND -- ESP32 GND
MQ-2 AO  -- ESP32 GPIO 34 (ADC1 pin)
MQ-2 DO  -- (not used in this tutorial; leave disconnected)

The MQ-2's heater draws about 150 mA. The ESP32's 5V pin can supply this from a USB port, but it pushes the limit. For long-term projects, power the heater from a separate 5V supply.

The 24-hour warm-up

The MQ-2 needs a long warm-up before readings are stable. The heater burns off contaminants and stabilizes the sensing element. Cold-start readings are nonsense.

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("MQ-2 warming up, wait 24 hours for stable readings...");
  Serial.println("Pre-warm readings will be meaningless.");
}

void loop() {
  int raw = analogRead(MQ2_PIN);
  Serial.print("Raw: ");
  Serial.println(raw);
  delay(5000);
}

After the sensor has been powered for 24 hours, the readings will stabilize. Many projects get away with 5-10 minutes of warm-up if the sensor was recently used, but for first-time use, 24 hours is the safe answer.

The code

ESP32 (Arduino)

const int MQ2_PIN = 34;

void setup() {
  Serial.begin(115200);
  delay(1000);
  analogReadResolution(12);
  Serial.println("MQ-2 sensor active (warm-up complete)");
}

void loop() {
  int raw = analogRead(MQ2_PIN);
  Serial.print("Raw: ");
  Serial.println(raw);
  delay(1000);
}

Arduino (Uno, Nano, Mega)

const int MQ2_PIN = A0;   // any analog pin

void setup() {
  Serial.begin(9600);
  delay(1000);
  Serial.println("MQ-2 sensor active (warm-up complete)");
}

void loop() {
  int raw = analogRead(MQ2_PIN);   // 0-1023
  Serial.print("Raw: ");
  Serial.println(raw);
  delay(1000);
}

The Uno's ADC is 10-bit. The threshold and calibration values from the ESP32 example need to be halved for the Uno. Take the raw value in clean air, divide by 2, and use that as your "safe" threshold.

MicroPython (ESP32 or Pico)

from machine import ADC, Pin
import time

# ESP32 ADC1: GPIO 34-39
# Pico ADC: GPIO 26-29
mq2 = ADC(Pin(34))
mq2.atten(ADC.ATTN_11DB)   # full 0-3.3V range

print('MQ-2 sensor active (warm-up complete)')
while True:
    raw = mq2.read_u16() >> 4   # 0-4095
    print(f'Raw: {raw}')
    time.sleep(1)

Raspberry Pi Python (with MCP3008)

from gpiozero import MCP3008
import time

mq2 = MCP3008(channel=0)

print('MQ-2 sensor active (warm-up complete)')
while True:
    raw = int(mq2.value * 1024)   # MCP3008 is 10-bit
    print(f'Raw: {raw}')
    time.sleep(1)

Same wiring as the soil moisture tutorial. Enable SPI on the Pi first.

What you should see

Upload. The raw value will be somewhere in the 0-4095 range. Lower means more gas (the sensor's resistance drops as gas concentration rises).

Converting to PPM

The MQ-2 datasheet has a chart of resistance ratio vs. gas concentration. The conversion is approximate. Use the chart in the datasheet for the gas you care about.

For LPG (liquefied petroleum gas, the most common use case):

const float R0 = 10.0;   // sensor resistance in clean air (calibrate per sensor)
const float RL = 5.0;    // load resistance on the module (usually 5k ohm)

float readLPGppm() {
  int raw = analogRead(MQ2_PIN);
  float voltage = raw * 3.3 / 4095.0;
  float rs = (3.3 - voltage) / voltage * RL;   // sensor resistance
  float ratio = rs / R0;
  // From MQ-2 datasheet: ratio of 0.4 ~= 200 ppm LPG
  // ratio of 0.2 ~= 1000 ppm LPG
  // ratio of 0.1 ~= 5000 ppm LPG
  // Approximate log-linear interpolation:
  float ppm = 1000.0 * pow(0.4 / ratio, 2.3);
  return ppm;
}

The formula is approximate. Real MQ-2 readings vary 20-50% from sensor to sensor. The R0 value needs to be calibrated per sensor (the datasheet says 10k in clean air, but real sensors vary).

Calibration in clean air

For accurate readings, calibrate the sensor in clean outdoor air or in a room with no combustible gases:

void calibrate() {
  Serial.println("Calibrating MQ-2 in clean air, wait 5 minutes...");
  delay(300000);
  int raw = analogRead(MQ2_PIN);
  float voltage = raw * 3.3 / 4095.0;
  float R0 = (3.3 - voltage) / voltage * RL;
  Serial.print("Calibration R0: ");
  Serial.println(R0);
  // Write this to EEPROM or hardcode it for future runs
}

The R0 value you measure is what to use in the conversion formula.

The "gas detected" alarm

For most projects (gas leak detection), you do not need actual PPM. You just need to know "is the gas level above some threshold":

const int ALARM_THRESHOLD = 1500;   // calibrate per environment

void loop() {
  int raw = analogRead(MQ2_PIN);
  if (raw < ALARM_THRESHOLD) {
    Serial.println("Gas detected!");
    digitalWrite(BUZZER_PIN, HIGH);
  } else {
    digitalWrite(BUZZER_PIN, LOW);
  }
  delay(500);
}

Pick a threshold based on what your sensor reads in clean air vs. in a known gas concentration. Test with a controlled source if possible (a little propane from a lighter, in a ventilated area).

What the MQ-2 cannot do

Be honest about what MQ sensors can and cannot tell you:

  • Cannot distinguish gases. LPG, methane, propane, alcohol, and smoke all register similarly. Use a more specific sensor if you need to identify the gas.
  • Cannot quantify accurately. PPM readings are approximate.
  • Cannot detect below ~100 ppm. For lower concentrations, you need a more sensitive sensor.
  • Sensor drifts over time. The MQ-2's baseline shifts as the sensor ages. Recalibrate every few months.
  • Cannot detect carbon monoxide reliably. Use an MQ-7 or a dedicated CO sensor.

For safety-critical applications (gas leak alarms, fire alarms), use the MQ-2 as a "something is wrong" indicator, not a primary safety device. Pair it with a commercial gas detector for anything that has to trigger evacuation.

What you learned

  • The MQ-2 detects combustible gases (LPG, propane, methane, smoke) but cannot distinguish between them.
  • It needs a 24-hour warm-up for stable readings.
  • The analog output is approximate; calibrate per sensor.
  • Use it as a "something is wrong" indicator, not a safety device.

When something breaks

  • Readings are 0 all the time. Sensor is not powered (5V required), or the analog pin is wrong.
  • Readings are 4095 all the time. Sensor is in extremely clean air (unusual), or the wiring is wrong.
  • Readings fluctuate wildly. Inconsistent power, or heater is not at temperature. Wait longer.
  • Sensor stops responding after a few weeks. The sensing element is contaminated. Some can be revived by baking at high temperature (about 200°C for 24 hours). Most just need replacement.

What to build next

  • The PIR motion tutorial combines with this for a kitchen safety alarm that wakes on motion and checks gas.
  • The book ESP32 Smart Home covers gas leak detection with multiple sensor types and a real alarm panel.
  • The book ESP32 Safety Systems covers proper sensor placement, calibration, and fail-safe design.

Chapter 22

ESP32: receive IR remote signals with the VS1838B

esp32 · 20 min

The VS1838B is the IR receiver module I default to for any project that needs a wireless button. It is a 3-pin module (signal, VCC, GND) that demodulates 38 kHz IR signals and outputs a clean digital pulse train matching the original remote's protocol. Wire it to any GPIO, point a remote at it, and you can read button presses as simple values.

This tutorial covers the wiring, the library, decoding the most common protocols (NEC, Sony, RC5), and the pattern for mapping button presses to actions in your project.

What you need

  • ESP32 dev board
  • VS1838B IR receiver module (3-pin variant with the metal can; about $1 from anywhere). The TSOP38238 is the equivalent from Vishay and is what Adafruit sells.
  • Any IR remote. TV remote, AC remote, the cheap 21-button remotes from AliExpress. All work.
  • 3 jumper wires

Wiring

The VS1838B has 3 pins: OUT, GND, VCC.

VS1838B VCC -- ESP32 3.3V
VS1838B GND -- ESP32 GND
VS1838B OUT -- ESP32 GPIO 4

That is the entire wiring. The module's OUT pin goes HIGH when no IR signal is detected and pulses LOW when a 38 kHz modulated signal is present. The library decodes the pulse train.

The VS1838B works on 3.3V or 5V. Use 3.3V to keep the ESP32 safe. Some modules have a metal can that is also the GND pin. Check the silkscreen.

Install library

Sketch >> Include Library >> Manage Libraries >> search for IRremoteESP8266. Install it. The library supports the ESP32 even though the name says ESP8266.

The code

Pick the language tab for your board.

ESP32 (Arduino)

#include <IRrecv.h>
#include <IRutils.h>

const int IR_PIN = 4;

IRrecv irrecv(IR_PIN);
decode_results results;

void setup() {
  Serial.begin(115200);
  delay(1000);
  irrecv.enableIRIn();
  Serial.println("IR receiver ready. Point a remote and press a button.");
}

void loop() {
  if (irrecv.decode(&results)) {
    Serial.print("Protocol: ");
    Serial.print(typeToString(results.decode_type).c_str());
    Serial.print("  Value: 0x");
    Serial.print(results.value, HEX);
    Serial.print("  Bits: ");
    Serial.println(results.bits);
    irrecv.resume();
  }
}

Arduino (Uno, Nano, Mega)

#include <IRrecv.h>
#include <IRutils.h>

const int IR_PIN = 2;   // pin 2 is the Timer2 interrupt pin on Uno/Nano, which the library needs

IRrecv irrecv(IR_PIN);
decode_results results;

void setup() {
  Serial.begin(115200);
  delay(1000);
  irrecv.enableIRIn();
  Serial.println("IR receiver ready.");
}

void loop() {
  if (irrecv.decode(&results)) {
    Serial.print("Protocol: ");
    Serial.print(typeToString(results.decode_type).c_str());
    Serial.print("  Value: 0x");
    Serial.print(results.value, HEX);
    Serial.print("  Bits: ");
    Serial.println(results.bits);
    irrecv.resume();
  }
}

The IR library needs a pin that supports Pin Change Interrupts (Uno/Nano: pin 2 or 3; Mega: pin 2, 3, 18, 19, 20, 21). Avoid pin 13 because the onboard LED interferes.

MicroPython (ESP32 or Pico)

from machine import Pin, Timer
from ir_rx import IR_RX

# pip-install ir_rx first, or copy ir_rx.py from
# https://github.com/peterhinch/micropython_ir
# On the Pico, use GPIO 4. On the ESP32, also works on GPIO 4.

ir_pin = Pin(4, Pin.IN, Pin.PULL_UP)

def callback(data, addr, ctrl):
    if data < 0:   # repeat code
        print('repeat')
    else:
        print(f'addr=0x{addr:02x} data=0x{data:02x} ctrl=0x{ctrl:02x}')

ir = IR_RX(ir_pin, callback)
print('IR receiver ready. Point a remote and press a button.')

Raspberry Pi Python

# Uses gpiozero. The LIRC daemon can also decode IR but gpiozero + a
# edge-detected GPIO is the simplest pattern for just "did something
# just transmit."
import gpiozero
import time

# On the Pi, use BCM pin 4 (physical pin 7). The VS1838B is a digital
# output; this just counts falling edges so you can see the bursts of
# 38 kHz modulation that the receiver has already demodulated.

ir_pin = gpiozero.DigitalInputDevice(4, pull_up=True)
last_event = 0
events = 0

def on_change():
    global last_event, events
    now = time.time()
    if now - last_event > 0.05:   # new burst
        events = 0
    events += 1
    last_event = now

ir_pin.when_activated = on_change
print('IR receiver ready. Watch Serial (or just count edges).')

while True:
    time.sleep(0.1)

For real IR decoding on a Pi (protocol-aware, button-identifying), install LIRC and configure /etc/lirc/lircd.conf for your remote. That is a separate, larger setup.

What you should see

Upload the ESP32 or Arduino version. Open Serial Monitor. Point a remote at the receiver and press a button. You should see lines like:

Protocol: NEC  Value: 0x20DF10EF  Bits: 32
Protocol: NEC  Value: 0x20DF906F  Bits: 32

Each button press gives a unique hex value. The protocol is whatever the remote uses (NEC is most common for cheap remotes; Sony uses SIRC; RC5 is on older Philips devices).

The protocols

The library decodes dozens of protocols. The most common:

  • NEC: 32 bits, used by most cheap remotes and many TVs. The 8-bit address is followed by the 8-bit command and the 8-bit inverted command.
  • Sony SIRC: 12, 15, or 20 bits. Used by Sony devices.
  • RC5 / RC6: Philips protocol. Toggle bit complicates the decoding.
  • Samsung: 32 bits. Similar to NEC but with different timing.

For most projects, you do not need to know the protocol details. You just need the hex value of each button. Write down which value corresponds to which button on your remote, and use those values in your project.

Mapping buttons to actions

The pattern is a switch statement on the hex value:

void loop() {
  if (irrecv.decode(&results)) {
    switch (results.value) {
      case 0x20DF10EF:   // power button on a typical NEC remote
        Serial.println("POWER");
        break;
      case 0x20DF906F:   // volume up
        Serial.println("VOL+");
        break;
      case 0x20DF8877:   // menu
        Serial.println("MENU");
        break;
      default:
        Serial.print("Unknown: 0x");
        Serial.println(results.value, HEX);
        break;
    }
    irrecv.resume();
  }
}

To get the hex values for your specific remote, run the sketch above and write down which button produces which value. Different remotes produce different values even for the same button. The library decodes the protocol but the address and command are remote-specific.

Repeat codes

When you hold a button down, the remote sends the code once, then sends repeat codes. The library has a separate path for repeat codes:

void loop() {
  if (irrecv.decode(&results)) {
    if (results.value == 0xFFFFFFFF) {
      // repeat code
      Serial.println("REPEAT");
    } else {
      // new code
      handleCode(results.value);
    }
    irrecv.resume();
  }
}

Use repeat codes for "hold to scroll" or "hold to dim" behaviors. They fire every ~100 ms while the button is held.

Sending IR (the other half)

The library also sends IR signals. With an IR LED and a transistor, you can control any device that has an IR remote:

#include <IRsend.h>

IRsend irsend(IR_PIN);   // use the same pin or a different one for the LED

void sendPower() {
  irsend.sendNEC(0x20DF10EF);
}

Wire an IR LED through a 100 ohm resistor to GPIO 4 (or any GPIO), with a 2N2222 transistor if you want maximum range. The library handles the 38 kHz modulation in software.

Common uses

  • Home automation: cheap remotes as scene controllers.
  • Camera shutter: any IR remote can trigger an ESP32 camera.
  • Robot control: cheap toy-car-style remotes work great.
  • Universal remote hub: combine IR receive and send to make the ESP32 a universal remote controller.

What you learned

  • The VS1838B is the standard 3-pin IR receiver. 3.3V, GND, signal.
  • Use the IRremoteESP8266 library on the ESP32.
  • Each button press is a unique hex value per protocol per remote.
  • Map hex values to actions in a switch statement.

When something breaks

  • No output on Serial Monitor. Library not installed correctly, or you forgot irrecv.enableIRIn() in setup().
  • Every press shows the same value. The remote is using a different protocol than you expect. Try decode_type to see what the library thinks it is.
  • Random values when no remote is pressed. Electrical noise. Add a 100nF capacitor across the VS1838B's VCC and GND pins.
  • Short range (under 1 meter). The receiver is pointed wrong, or sunlight is washing out the IR signal. Test indoors away from windows.

What to build next

  • The HC-SR04 tutorial combines with this for IR-controlled obstacle-avoiding robots.
  • The book ESP32 IoT Projects covers IR-controlled home automation with a custom dashboard.
  • The book Production IoT with ESP32 covers IR repeaters for controlling existing appliances.

Chapter 23

ESP32: detect motion with a PIR motion sensor

esp32 · 20 min

The PIR motion sensor (HC-SR501 is the most common module) is the cheapest way to detect that someone is in a room. It is a $1 sensor that outputs HIGH when it detects motion and LOW otherwise. Wire it to a GPIO, and you have a motion detector that runs for years on a small battery.

The sensor itself is a passive infrared detector behind a fresnel lens. It detects the change in infrared radiation when a warm body moves across its field of view. It is sensitive to people and large animals; not sensitive to static objects.

This tutorial covers wiring, the two adjustment pots, the warm-up time, and the project patterns (security, automation, wake-on-motion).

What you need

  • ESP32 dev board
  • HC-SR501 PIR motion sensor module (about $1 from anywhere; the variants with three pins labelled VCC, OUT, GND)
  • 3 jumper wires

Wiring

HC-SR501 VCC -- ESP32 5V (the module needs 5V; 3.3V may not work)
HC-SR501 GND -- ESP32 GND
HC-SR501 OUT -- ESP32 GPIO 4

The HC-SR501 runs on 4.5-20V, so the ESP32's 5V pin works. The output is 3.3V-compatible HIGH/LOW despite running on 5V.

If you must use 3.3V (e.g. running from battery), some HC-SR501 clones work on 3.3V. The original ones do not. Test with your specific module.

The two adjustment pots

The HC-SR501 has two potentiometers on the back:

Pot Adjustment Effect
Sx (left, looking at the back) Sensitivity Range of detection, 3-7 meters
Tx (right) Time delay How long OUT stays HIGH after a trigger, 5 sec to 5 min

Turning the sensitivity pot clockwise increases the range. Turning the time-delay pot clockwise increases how long the output stays HIGH after a trigger.

For most projects, sensitivity maxed out (clockwise) and time delay short (counterclockwise to 5 seconds) is the right starting point.

The "time delay" setting is not a re-trigger delay. It is the minimum time OUT stays HIGH after a single trigger. After the delay, OUT goes LOW and the sensor waits for the next trigger. If you want a longer trigger window, set this higher.

The warm-up time

The HC-SR501 needs about 30-60 seconds to stabilize after power-up. During this time, OUT may randomly go HIGH and LOW. Ignore the readings during warm-up:

void setup() {
  Serial.begin(115200);
  delay(1000);
  pinMode(PIR_PIN, INPUT);
  Serial.println("PIR warming up, ignoring motion for 60 seconds...");
  delay(60000);   // wait for the sensor to stabilize
  Serial.println("PIR ready.");
}

If you do not wait, your project will fire false alarms on startup.

The code

ESP32 (Arduino)

const int PIR_PIN = 4;

unsigned long lastTrigger = 0;
bool motionActive = false;
const unsigned long MOTION_TIMEOUT = 10000;

void setup() {
  Serial.begin(115200);
  delay(1000);
  pinMode(PIR_PIN, INPUT);
  Serial.println("PIR warming up, wait 60s...");
  delay(60000);
  Serial.println("PIR ready.");
}

void loop() {
  bool reading = digitalRead(PIR_PIN);
  if (reading) {
    lastTrigger = millis();
    if (!motionActive) {
      Serial.println("Motion START");
      motionActive = true;
    }
  } else {
    if (motionActive && (millis() - lastTrigger > MOTION_TIMEOUT)) {
      Serial.println("Motion END");
      motionActive = false;
    }
  }
  delay(50);
}

Arduino (Uno, Nano, Mega)

const int PIR_PIN = 2;   // any digital pin works

unsigned long lastTrigger = 0;
bool motionActive = false;
const unsigned long MOTION_TIMEOUT = 10000;

void setup() {
  Serial.begin(9600);   // 9600 is more reliable than 115200 on the Uno's USB bridge
  delay(1000);
  pinMode(PIR_PIN, INPUT);
  Serial.println("PIR warming up, wait 60s...");
  delay(60000);
  Serial.println("PIR ready.");
}

void loop() {
  bool reading = digitalRead(PIR_PIN);
  if (reading) {
    lastTrigger = millis();
    if (!motionActive) {
      Serial.println("Motion START");
      motionActive = true;
    }
  } else {
    if (motionActive && (millis() - lastTrigger > MOTION_TIMEOUT)) {
      Serial.println("Motion END");
      motionActive = false;
    }
  }
  delay(50);
}

The Uno's ATmega328 has no ADC2-vs-Wi-Fi trap, so any GPIO pin works.

MicroPython (ESP32 or Pico)

from machine import Pin
import time

PIR_PIN = 4
pir = Pin(PIR_PIN, Pin.IN)
motion_active = False
last_trigger = 0
TIMEOUT_MS = 10_000

print('PIR warming up, wait 60s...')
time.sleep_ms(60_000)
print('PIR ready.')

while True:
    reading = pir.value()
    now = time.ticks_ms()
    if reading:
        last_trigger = now
        if not motion_active:
            print('Motion START')
            motion_active = True
    else:
        if motion_active and (time.ticks_diff(now, last_trigger) > TIMEOUT_MS):
            print('Motion END')
            motion_active = False
    time.sleep_ms(50)

Raspberry Pi Python (with gpiozero)

import gpiozero
import time

pir = gpiozero.MotionSensor(4)   # BCM pin 4 (physical pin 7)
print('PIR warming up, wait 60s...')
time.sleep(60)
print('PIR ready.')

pir.when_motion = lambda: print('Motion START')
pir.when_no_motion = lambda: print('Motion END')

while True:
    time.sleep(1)

gpiozero.MotionSensor does the warm-up, debouncing, and event handling for you. The when_motion and when_no_motion callbacks fire automatically.

What you should see

The 10-second timeout is a tradeoff. The HC-SR501's OUT stays HIGH for the time-delay setting (5 sec by default), so we set our timeout higher than that. Adjust based on your time-delay setting.

The retrigger jumper

The HC-SR501 has a 3-pin jumper on the back labeled H and L. The default is H (single trigger mode). In single trigger mode, the OUT goes HIGH once, then ignores new triggers until the time delay expires. In L (repeatable trigger mode), every motion event resets the time delay, keeping OUT HIGH as long as motion continues.

For most projects, H is fine. For "motion just ended" detection, you need L mode and a longer time delay.

Project patterns

Security alarm

Trigger a buzzer or send an MQTT alert when motion is detected while the system is armed:

bool armed = false;

void loop() {
  if (motionActive && armed) {
    mqtt.publish("ctrlaltbrian/security/motion", "ALARM");
    digitalWrite(BUZZER_PIN, HIGH);
    delay(1000);
    digitalWrite(BUZZER_PIN, LOW);
  }
}

Wake-on-motion for battery projects

The PIR can wake the ESP32 from deep sleep. Wire the OUT pin to a GPIO that supports esp_sleep_enable_ext0_wakeup() (any GPIO except 6-11):

#define BUTTON_PIN_BITMASK (1ULL << PIR_PIN)

void setup() {
  esp_sleep_enable_ext0_wakeup(PIR_PIN, 1);   // wake when PIR goes HIGH
  // ...
  esp_deep_sleep_start();
}

When the ESP32 wakes from deep sleep, it knows motion happened. Read the PIR, take action, go back to sleep. Battery life is measured in months.

Lighting automation

Turn on a relay or smart bulb when someone enters a room:

void loop() {
  if (motionActive) {
    digitalWrite(LIGHT_PIN, HIGH);
  } else if (!motionActive && (millis() - lastTrigger > 60000)) {
    digitalWrite(LIGHT_PIN, LOW);
  }
}

The 60-second "no motion" timeout is the right delay for a room. If the person is reading, they need the light to stay on.

What you learned

  • The HC-SR501 PIR sensor detects motion of warm bodies via a fresnel lens.
  • Wiring is 3 pins: VCC (5V), GND, OUT.
  • Wait 60 seconds after power-up for the sensor to stabilize.
  • Two adjustment pots control sensitivity (range) and time delay (how long OUT stays HIGH).

When something breaks

  • False triggers constantly. Sensitivity too high, or sensor is pointed at a window (sunlight), a heating vent, or a busy area.
  • No triggers at all. Wiring wrong, or the warm-up period is not over. Check the OUT pin with a multimeter; it should toggle between 0V and 3.3V when motion happens.
  • Sensor triggers when nothing is moving. Sensitivity too high, or a draft is moving a curtain. Reposition or reduce sensitivity.
  • OUT stays HIGH forever. Time delay is set too high, or the sensor is in single-trigger mode and a person is constantly moving.

What to build next

  • The HC-SR04 tutorial adds distance sensing to a PIR for projects that need to know if someone is close, not just present.
  • The deep sleep + PIR wake tutorial covers the wake-on-motion battery pattern in depth.
  • The book ESP32 Smart Home covers building a complete home security system with multiple PIR sensors and a dashboard.

Chapter 24

ESP32: read a soil moisture sensor (capacitive, not resistive)

esp32 · 20 min

There are two kinds of soil moisture sensors: resistive and capacitive. The resistive ones use two exposed metal probes and measure the resistance between them. They corrode within a few weeks in soil and start reading wrong values. The capacitive ones measure the dielectric constant of the soil around a covered probe and last for years.

This tutorial covers the capacitive sensor (the one that looks like a small stick with a flat PCB at the top). Skip the resistive sensor. The $1 you save is not worth the false readings.

What you need

  • ESP32 dev board
  • Capacitive soil moisture sensor (the v1.2 from any of the usual vendors; $1-2 each)
  • 3 jumper wires
  • A potted plant or a glass of water for testing

Wiring

Sensor VCC -- ESP32 3.3V (NOT 5V; capacitive sensors do not need it)
Sensor GND -- ESP32 GND
Sensor AOUT -- ESP32 GPIO 34 (an ADC1 pin; see the ADC tutorial)

Use 3.3V, not 5V. The sensor's analog output range is calibrated for 3.3V on most variants. Powering at 5V gives you a higher reading range but is out of spec for the sensor and reduces accuracy.

Why capacitive, not resistive

The resistive sensor:

  • Two exposed metal probes in the soil
  • DC current flows from one probe to the other through the wet soil
  • The resistance drops as moisture increases
  • The probes corrode because the DC current + moisture = electrolysis
  • After 2-4 weeks, the probes are visibly corroded and the readings are unreliable

The capacitive sensor:

  • One covered probe acts as one plate of a capacitor; the soil around it acts as the dielectric
  • The capacitance changes with moisture content (water has a much higher dielectric constant than dry soil)
  • The sensor measures capacitance and outputs a voltage proportional to it
  • The covered probe does not corrode
  • The sensor lasts for years in soil

There is a time-and-place for resistive sensors (instantaneous readings in dry, non-corrosive media). For soil moisture, capacitive is the right call.

The code

ESP32 (Arduino)

const int MOISTURE_PIN = 34;

const int DRY_VALUE = 2800;   // sensor in air
const int WET_VALUE = 400;    // sensor in water

float readMoisturePercent() {
  int raw = analogRead(MOISTURE_PIN);
  float pct = (float)(DRY_VALUE - raw) / (DRY_VALUE - WET_VALUE) * 100.0;
  return constrain(pct, 0.0, 100.0);
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  analogReadResolution(12);
}

void loop() {
  Serial.print("Moisture: ");
  Serial.print(readMoisturePercent());
  Serial.println("%");
  delay(1000);
}

Arduino (Uno, Nano, Mega)

const int MOISTURE_PIN = A0;   // ADC1-equivalent pin on Uno

const int DRY_VALUE = 720;     // ADC is 10-bit (0-1023) on the Uno; halve the 12-bit values
const int WET_VALUE = 100;

float readMoisturePercent() {
  int raw = analogRead(MOISTURE_PIN);
  float pct = (float)(DRY_VALUE - raw) / (DRY_VALUE - WET_VALUE) * 100.0;
  return constrain(pct, 0.0, 100.0);
}

void setup() {
  Serial.begin(9600);
  delay(1000);
}

void loop() {
  Serial.print("Moisture: ");
  Serial.print(readMoisturePercent());
  Serial.println("%");
  delay(1000);
}

The Uno's ADC is 10-bit (0-1023), not 12-bit. Calibrate DRY_VALUE and WET_VALUE to your specific sensor in your specific soil.

MicroPython (ESP32 or Pico)

from machine import ADC, Pin
import time

# ESP32: GPIO 34-39 are ADC1
# Pico: GPIO 26-29 are ADC pins
moisture = ADC(Pin(34))
moisture.atten(ADC.ATTN_11DB)   # full 0-3.3V range on ESP32

DRY_VALUE = 2800
WET_VALUE = 400

def read_moisture_pct():
    raw = moisture.read_u16() >> 4   # ESP32 returns 0-65535; shift to 0-4095
    pct = (DRY_VALUE - raw) / (DRY_VALUE - WET_VALUE) * 100.0
    return max(0.0, min(100.0, pct))

print('Moisture sensor ready')
while True:
    print(f'Moisture: {read_moisture_pct():.1f}%')
    time.sleep(1)

For the Pico, use moisture = ADC(Pin(26)) and moisture.read_u16() returns 0-65535 directly (no shift needed).

Raspberry Pi Python (with MCP3008 over SPI)

The Pi has no native ADC. The MCP3008 is the standard 8-channel 10-bit ADC chip, $2 from anywhere. Wire it to the Pi's SPI pins.

import gpiozero
from gpiozero import MCP3008
import time

# MCP3008 channel 0, Vref 3.3V, 10-bit ADC (0-1023)
moisture = MCP3008(channel=0)

DRY_VALUE = 0.85    # voltage ratio at "air"
WET_VALUE = 0.20    # voltage ratio at "water"

def read_moisture_pct():
    # MCP3008.read_u16 returns 0-1 (ratio)
    ratio = moisture.value
    pct = (DRY_VALUE - ratio) / (DRY_VALUE - WET_VALUE) * 100.0
    return max(0.0, min(100.0, pct))

print('Moisture sensor ready')
while True:
    print(f'Moisture: {read_moisture_pct():.1f}%')
    time.sleep(1)

Enable SPI on the Pi first: sudo raspi-config >> Interface Options

SPI >> Enable. The MCP3008 also needs pip install gpiozero (it ships with the standard Raspberry Pi OS image).

What you should see

Upload. Open Serial Monitor. You should see a value that changes as you move the sensor in and out of soil.

The raw value range depends on the sensor and your soil:

  • Air (sensor in air): 2500-3000 (high = dry)
  • Dry soil: 1800-2500
  • Moist soil: 1000-1800
  • Wet soil: 500-1000
  • In water: 0-500

These are typical ranges for the v1.2 capacitive sensor. Yours will vary.

The percentage conversion

The raw value does not mean anything without calibration. To convert to a meaningful percentage, you need to measure the sensor in air (the "dry" reading) and in water (the "wet" reading) once:

const int DRY_VALUE = 2800;    // sensor in air
const int WET_VALUE = 400;     // sensor in water

float readMoisturePercent() {
  int raw = analogRead(MOISTURE_PIN);
  float pct = (float)(DRY_VALUE - raw) / (DRY_VALUE - WET_VALUE) * 100.0;
  return constrain(pct, 0.0, 100.0);
}

void loop() {
  Serial.print("Moisture: ");
  Serial.print(readMoisturePercent());
  Serial.println("%");
  delay(1000);
}

After running this for a day in a real plant, you'll want to adjust the DRY_VALUE and WET_VALUE to match your specific soil.

Per-soil calibration

Different soils have different dielectric constants. Clay holds more water and reads higher than sandy soil at the same moisture level. For accurate readings:

  1. Saturate a soil sample with water (let it soak for an hour).
  2. Insert the sensor. Record the reading. This is your 100% wet value.
  3. Let the soil dry in the sun for a day or two. Insert the sensor. This is your 0% dry value.
  4. Use those two values in your formula.

For most projects (knowing "is the plant thirsty"), rough calibration is enough. For agricultural research, use a gravimetric measurement (weighing wet vs oven-dried soil) as the reference.

The "wait between readings" gotcha

The capacitive sensor's output is a high-impedance analog signal. The ADC needs a small amount of time to settle. Reading immediately after powering the sensor can give a wrong value:

float readMoisturePercent() {
  // Discard the first reading after a power-up
  analogRead(MOISTURE_PIN);
  delay(10);
  int raw = analogRead(MOISTURE_PIN);
  // ... convert
}

For most projects, the loop delay is enough that this is not an issue.

The water pump project pattern

Most soil moisture projects end with "water the plant if dry." The pattern is:

const int PUMP_PIN = 5;
const float THRESHOLD_PERCENT = 30.0;

void loop() {
  float moisture = readMoisturePercent();
  if (moisture < THRESHOLD_PERCENT) {
    Serial.println("Soil dry, watering for 5 seconds");
    digitalWrite(PUMP_PIN, HIGH);
    delay(5000);
    digitalWrite(PUMP_PIN, LOW);
    // wait for water to absorb before re-reading
    delay(60000);
  } else {
    Serial.print("Soil moist (");
    Serial.print(moisture);
    Serial.println("%), skipping");
  }
  delay(60000);   // check once per minute
}

Use a 12V peristaltic pump or a 5V submersible pump, with a MOSFET to switch it. The pump draws more current than the ESP32 can deliver directly.

What you learned

  • Capacitive soil moisture sensors last for years; resistive ones corrode in weeks.
  • Wiring is 3 pins: VCC (3.3V), GND, AOUT to an ADC1 pin.
  • Convert raw ADC values to percent using air and water calibration.
  • Per-soil calibration matters for accuracy.

When something breaks

  • Readings are 0 all the time. Sensor is shorted (water inside the probe), or wired wrong. Pull it out and check.
  • Readings are 4095 all the time. Sensor is not in soil, or wiring is wrong.
  • Readings change very slowly. Normal for capacitive sensors in wet soil; they take a few minutes to settle.
  • Sensor corrodes quickly. You bought a resistive sensor by mistake. Look for the "capacitive" label.

What to build next

  • The BME280 tutorial combines with this for indoor plant monitoring: soil moisture, temperature, humidity, light.
  • The ESP32 MQTT tutorial publishes soil moisture to a dashboard.
  • The book ESP32 Smart Garden covers automated watering systems with multiple sensors and pumps.

Chapter 25

ESP32: measure DC current with the ACS712 current sensor

esp32 · 25 min

The ACS712 is the chip I reach for when I need to measure the current draw of a battery-powered device. It uses a Hall-effect sensor to measure the magnetic field around a current-carrying wire, so it is fully isolated from the circuit being measured. The output is a voltage proportional to current, with versions available for 5A, 20A, and 30A ranges.

This tutorial covers wiring, the zero-current calibration, the conversion formula, and the patterns for monitoring battery devices over time.

What you need

  • ESP32 dev board
  • ACS712 module (the breakout board with screw terminals; about $3). Pick the right version for your current range:
    • ACS712-05B: ±5A, 185 mV/A sensitivity
    • ACS712-20A: ±20A, 100 mV/A sensitivity
    • ACS712-30A: ±30A, 66 mV/A sensitivity
  • 3 jumper wires
  • Multimeter (for zero-current calibration)

Wiring

ACS712 VCC -- ESP32 5V (the chip needs 5V for full output range)
ACS712 GND -- ESP32 GND
ACS712 OUT -- ESP32 GPIO 34 (an ADC1 pin)

The ACS712's output is centered at VCC/2 = 2.5V (when running on 5V). Current flowing one way pushes the voltage above 2.5V; current flowing the other way pushes it below 2.5V.

If you power the ACS712 from 5V and read it with an ESP32 running on 3.3V, the output range is 0-5V. The ESP32's ADC can only read 0-3.3V. The ACS712 output can exceed 3.3V when current is high. Add a voltage divider on the output to keep it within range, or use the 3.3V variant of the module if available.

Some ACS712 modules have a 3.3V regulator on the output. Check yours. The Adafruit and SparkFun modules do.

The zero-current calibration

The ACS712's output at zero current is not exactly VCC/2. It varies from sensor to sensor, typically 2.45-2.55V. You need to measure the actual zero-current output for your specific sensor:

const int ACS_PIN = 34;
float zeroCurrentVoltage;

void setup() {
  Serial.begin(115200);
  delay(1000);

  // Read the zero-current voltage 100 times and average
  long sum = 0;
  for (int i = 0; i < 100; i++) {
    sum += analogRead(ACS_PIN);
    delay(10);
  }
  float avgRaw = sum / 100.0;
  zeroCurrentVoltage = avgRaw * 3.3 / 4095.0;
  Serial.print("Zero current voltage: ");
  Serial.println(zeroCurrentVoltage, 4);
}

void loop() {
  // ...
}

Run this once with NO current flowing through the sensor's input terminals (or with the input open). Write down the voltage. Hardcode it in the formula or save it to EEPROM.

The code

ESP32 (Arduino)

const int ACS_PIN = 34;
float zeroCurrentVoltage;

void setup() {
  Serial.begin(115200);
  delay(1000);

  long sum = 0;
  for (int i = 0; i < 100; i++) {
    sum += analogRead(ACS_PIN);
    delay(10);
  }
  float avgRaw = sum / 100.0;
  zeroCurrentVoltage = avgRaw * 3.3 / 4095.0;
  Serial.print("Zero current voltage: ");
  Serial.println(zeroCurrentVoltage, 4);
}

float readCurrentAmps() {
  int raw = analogRead(ACS_PIN);
  float voltage = raw * 3.3 / 4095.0;
  return (voltage - zeroCurrentVoltage) / 0.185;
}

void loop() {
  Serial.print("Current: ");
  Serial.print(readCurrentAmps());
  Serial.println(" A");
  delay(500);
}

Arduino (Uno, Nano, Mega)

const int ACS_PIN = A0;
float zeroCurrentVoltage;
const float VCC = 5.0;   // ACS712 powered from 5V; reference voltage is 5V
const float SENSITIVITY = 0.185;   // 5A version

void setup() {
  Serial.begin(9600);
  delay(1000);

  long sum = 0;
  for (int i = 0; i < 100; i++) {
    sum += analogRead(ACS_PIN);
    delay(10);
  }
  float avgRaw = sum / 100.0;
  zeroCurrentVoltage = (avgRaw / 1023.0) * VCC;
  Serial.print("Zero current voltage: ");
  Serial.println(zeroCurrentVoltage, 4);
}

float readCurrentAmps() {
  int raw = analogRead(ACS_PIN);
  float voltage = (raw / 1023.0) * VCC;
  return (voltage - zeroCurrentVoltage) / SENSITIVITY;
}

void loop() {
  Serial.print("Current: ");
  Serial.print(readCurrentAmps());
  Serial.println(" A");
  delay(500);
}

The Uno's ADC is 10-bit (0-1023) and uses the 5V supply as reference. The ESP32 example uses 3.3V reference; the formula adapts to whichever reference your board uses.

MicroPython (ESP32 or Pico)

from machine import ADC, Pin
import time

# ESP32 ADC1: GPIO 34-39
# Pico ADC: GPIO 26-29
acs = ADC(Pin(34))
acs.atten(ADC.ATTN_11DB)

def calibrate():
    total = 0
    for _ in range(100):
        total += acs.read_u16()
        time.sleep_ms(10)
    avg = total / 100
    return (avg / 65535) * 3.3   # ESP32 3.3V reference

def read_current_amps(zero_v):
    raw = acs.read_u16()
    voltage = (raw / 65535) * 3.3
    return (voltage - zero_v) / 0.185

print('Calibrating, do not connect any load...')
zero = calibrate()
print(f'Zero voltage: {zero:.4f}')

while True:
    amps = read_current_amps(zero)
    print(f'Current: {amps:.3f} A')
    time.sleep_ms(500)

Raspberry Pi Python (with MCP3008 over SPI)

The ACS712 is ratiometric: its output is VCC/2 + sensitivity * current. The MCP3008 reads voltages up to its Vref, so power the ACS712 from 3.3V and use the Pi's 3.3V reference. The ACS712-5A output range is then 1.65V (zero) ± 0.925V (±5A), well within the 0-3.3V input range.

from gpiozero import MCP3008
import time

acs = MCP3008(channel=0)
SENSITIVITY = 0.185   # ACS712-5A, in V/A; output is 1.65V + sensitivity * current

def calibrate():
    total = 0
    for _ in range(100):
        total += acs.value
        time.sleep(0.01)
    return (total / 100) * 3.3

def read_current_amps(zero_v):
    voltage = acs.value * 3.3
    return (voltage - zero_v) / SENSITIVITY

print('Calibrating, do not connect any load...')
zero = calibrate()
print(f'Zero voltage: {zero:.4f}')

while True:
    amps = read_current_amps(zero)
    print(f'Current: {amps:.3f} A')
    time.sleep(0.5)

What you should see

The reading is positive when current flows one way, negative when it flows the other way. The direction depends on how you connect the input terminals. If your readings come out negative when they should be positive, swap the input wires.

Calculating power and battery life

Current times voltage gives power. For a 12V battery powering a device drawing 0.5A:

power = 12V * 0.5A = 6 watts

For battery life, multiply current by time:

void loop() {
  float amps = readCurrentAmps();
  float volts = readBatteryVoltage();   // from the 18650 tutorial
  float watts = amps * volts;
  Serial.print("Power: ");
  Serial.print(watts);
  Serial.println(" W");
  delay(1000);
}

For a battery capacity in watt-hours, divide by the average power to get runtime.

The noise problem

The ACS712's output has some noise on it (a few mV peak-to-peak). For accurate readings, average:

float readCurrentAmpsSmoothed(int samples = 32) {
  long sum = 0;
  for (int i = 0; i < samples; i++) {
    sum += analogRead(ACS_PIN);
    delay(1);
  }
  float avgRaw = (float)sum / samples;
  float voltage = avgRaw * 3.3 / 4095.0;
  return (voltage - zeroCurrentVoltage) / SENSITIVITY;
}

32 samples at 1 ms each is 32 ms total. Fine for most projects.

For really clean readings, use an external ADC like the ADS1115 (16-bit I2C). The ACS712 + ADS1115 combination is the right way to do precision current measurement on the ESP32.

The "what is the device doing right now" project

A common use case is monitoring a device's power state over time:

const float IDLE_THRESHOLD = 0.05;   // amps; below this = idle

void loop() {
  float amps = readCurrentAmpsSmoothed();
  if (amps > 1.0) {
    Serial.println("Device is ACTIVE");
  } else if (amps > IDLE_THRESHOLD) {
    Serial.println("Device is IDLE (low power mode)");
  } else {
    Serial.println("Device is SLEEPING (off)");
  }
  delay(500);
}

This pattern works for monitoring IoT devices, appliances, and any device where you want to know its operational state without modifying the device itself.

What you learned

  • The ACS712 measures current using a Hall-effect sensor. Fully isolated from the measured circuit.
  • Choose the right sensitivity (5A, 20A, or 30A) for your current range.
  • Zero-current calibration is required. Measure with no current flowing.
  • Average multiple readings to reduce noise.

When something breaks

  • Readings are wildly wrong. Zero-current calibration is bad. Run the calibration again with the input wires disconnected.
  • Reading is always 0. Sensor is not powered (5V required), or the input wires are not passing any current.
  • Reading maxes out at the range. You picked the wrong sensitivity (e.g. 5A version measuring a 10A load).
  • Reading is noisy even after averaging. Add a 100nF capacitor across the OUT pin and GND at the sensor.

What to build next

  • The BME280 tutorial combines with this for a complete energy monitor: voltage, current, power, environment.
  • The MQTT tutorial publishes current readings to a dashboard.
  • The book ESP32 Energy Monitor covers building a whole-home electricity monitor with multiple ACS712 channels.

Chapter 26

ESP32: measure ambient light in lux with the BH1750

esp32 · 20 min

The BH1750 is the light sensor I default to when I need real lux values, not just "is it dark." It uses I2C, costs $2, and gives readings in lux from 1 to 65535. It is the right sensor for screen brightness control, daylight harvesting, plant growth monitoring, and any project that needs to know how bright the room actually is.

This tutorial covers wiring, the I2C library, the conversion, and the patterns for using lux values in projects.

What you need

  • ESP32 dev board
  • BH1750 breakout board (the GY-302 is the most common; $1-2 each)
  • 4 jumper wires

Wiring

The BH1750 uses I2C. Same pins as the BME280 and MPU6050:

BH1750 VCC -- ESP32 3.3V (NOT 5V; the BH1750 is 3.3V)
BH1750 GND -- ESP32 GND
BH1750 SDA -- ESP32 GPIO 21
BH1750 SCL -- ESP32 GPIO 22

The default I2C address is 0x23. Some boards have an ADDR pin; if yours does, connecting it to VCC changes the address to 0x5C.

Install library

Sketch >> Include Library >> Manage Libraries >> search for BH1750 by Christopher Laws. Install it.

The code

ESP32 (Arduino)

#include <Wire.h>
#include <BH1750.h>

BH1750 lightMeter(0x23);

void setup() {
  Serial.begin(115200);
  delay(1000);
  Wire.begin();
  if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
    Serial.println("BH1750 found");
  } else {
    Serial.println("Could not find BH1750");
    while (1);
  }
}

void loop() {
  if (lightMeter.measurementReady()) {
    float lux = lightMeter.readLightLevel();
    Serial.print("Light: ");
    Serial.print(lux);
    Serial.println(" lux");
  }
  delay(200);
}

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <BH1750.h>

BH1750 lightMeter(0x23);

void setup() {
  Serial.begin(9600);
  delay(1000);
  Wire.begin();
  if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
    Serial.println("BH1750 found");
  } else {
    Serial.println("Could not find BH1750");
    while (1);
  }
}

void loop() {
  if (lightMeter.measurementReady()) {
    float lux = lightMeter.readLightLevel();
    Serial.print("Light: ");
    Serial.print(lux);
    Serial.println(" lux");
  }
  delay(200);
}

The Uno's I2C pins are A4 (SDA) and A5 (SCL). The ESP32 default is GPIO 21/22; Wire.begin() uses the board defaults on both.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import time

# ESP32 default I2C pins: 21 (SDA), 22 (SCL)
# Pico default I2C pins: 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)
devices = i2c.scan()
print(f'I2C devices: {[hex(d) for d in devices]}')

BH1750_ADDR = 0x23
CONT_HIRES = 0x10   # continuous high-res mode

i2c.writeto(BH1750_ADDR, bytes([CONT_HIRES]))
time.sleep_ms(180)   # first measurement takes 180ms

while True:
    data = i2c.readfrom(BH1750_ADDR, 2)
    raw = (data[0] << 8) | data[1]
    lux = raw / 1.2   # per BH1750 datasheet
    print(f'Light: {lux:.1f} lux')
    time.sleep_ms(200)

Raspberry Pi Python

import smbus2
import time

bus = smbus2.SMBus(1)   # /dev/i2c-1 on modern Pi OS
BH1750_ADDR = 0x23
CONT_HIRES = 0x10

bus.write_byte(BH1750_ADDR, CONT_HIRES)
time.sleep(0.18)   # first measurement

while True:
    data = bus.read_i2c_block_data(BH1750_ADDR, 0x00, 2)
    raw = (data[0] << 8) | data[1]
    lux = raw / 1.2
    print(f'Light: {lux:.1f} lux')
    time.sleep(0.2)

Enable I2C on the Pi first: sudo raspi-config >> Interface Options >> I2C >> Enable. Then pip install smbus2 (preinstalled on Raspberry Pi OS).

What you should see

Upload. Open Serial Monitor. You should see lux values that change as you cover the sensor with your hand or shine a flashlight at it.

Typical values:

  • Dark room at night: 0-10 lux
  • Living room with lamps: 50-200 lux
  • Office with fluorescent lights: 300-500 lux
  • Overcast outdoor: 1000-2000 lux
  • Direct sunlight: 10000-100000 lux

The measurement modes

The BH1750 supports 4 modes with different resolution and speed:

Mode Resolution Measurement time Use case
CONTINUOUS_HIGH_RES_MODE 1 lux 120 ms Default; room light sensing
CONTINUOUS_HIGH_RES_MODE_2 0.5 lux 120 ms High accuracy at low light
CONTINUOUS_LOW_RES_MODE 4 lux 16 ms Fast response, low resolution
ONE_TIME_HIGH_RES_MODE 1 lux 120 ms Battery projects (sleep between readings)

For most projects, the default high-resolution mode is fine. For battery projects, the one-time modes let you take a single reading and then put the sensor back to sleep.

Why use lux, not just ADC

A photocell (the cheap light-dependent resistor) gives you a raw ADC value that depends on the sensor, the resistor value, and the supply voltage. It is not meaningful in absolute terms. The BH1750 gives you lux, which is a standardized unit of illuminance. Two BH1750s in the same room will give the same reading to within a few percent.

For projects where the absolute value matters (calibrating a screen brightness curve, comparing outdoor light to plant growth needs), lux is the only unit that works.

Screen brightness project

Use lux to set a display's brightness:

const int DISPLAY_PWM_PIN = 4;

void setup() {
  ledcSetup(0, 5000, 8);
  ledcAttachPin(DISPLAY_PWM_PIN, 0);
}

void loop() {
  if (lightMeter.measurementReady()) {
    float lux = lightMeter.readLightLevel();
    // Map 0-1000 lux to 20-255 brightness
    int brightness = constrain(map(lux, 0, 1000, 20, 255), 20, 255);
    ledcWrite(0, brightness);
  }
  delay(500);
}

The map is non-linear (human perception of brightness is logarithmic), but the linear mapping works as a first pass.

Plant growth project

Different plants need different lux levels. A few reference points:

  • Low light (snake plant, pothos): 50-200 lux
  • Medium light (most houseplants): 200-1000 lux
  • High light (succulents, herbs): 1000+ lux
void loop() {
  if (lightMeter.measurementReady()) {
    float lux = lightMeter.readLightLevel();
    if (lux < 200) {
      Serial.println("Too dim, consider a grow light");
    } else if (lux > 10000) {
      Serial.println("Direct sun, monitor for heat stress");
    } else {
      Serial.print("Good light: ");
      Serial.print(lux);
      Serial.println(" lux");
    }
  }
  delay(5000);
}

Multiple BH1750s on one I2C bus

The BH1750 supports two addresses (0x23 and 0x5C), so you can put two on one bus. For more, use a multiplexer.

BH1750 lightMeter1(0x23);
BH1750 lightMeter2(0x5C);   // ADDR pin tied to VCC on this one

void setup() {
  Wire.begin();
  lightMeter1.begin(BH1750::CONTINUOUS_HIGH_RES_MODE);
  lightMeter2.begin(BH1750::CONTINUOUS_HIGH_RES_MODE);
}

void loop() {
  float lux1 = lightMeter1.readLightLevel();
  float lux2 = lightMeter2.readLightLevel();
  // ...
}

What you learned

  • The BH1750 is a digital lux meter over I2C. No analog conversion, no calibration needed.
  • 1 to 65535 lux range, 1 lux resolution.
  • Same I2C wiring as the BME280 and MPU6050.
  • Use one-time mode for battery projects to take a single reading and sleep.

When something breaks

  • Readings are 0 or near 0. Sensor is in a dark space, or the I2C wiring is wrong.
  • Readings are 65535 (max). Sensor is in direct sunlight or pointed at a bright light. Normal.
  • "Could not find BH1750". Wrong address (try 0x5C), wrong wiring, or wrong supply voltage.
  • Readings fluctuate wildly. The sensor is near a fluorescent light with a 50/60 Hz flicker. Average multiple readings, or move the sensor away from the flickering source.

What to build next

  • The BME280 tutorial combines with this for a complete indoor environment monitor (temperature, humidity, pressure, light).
  • The deep sleep tutorial uses BH1750 as a wake trigger for daylight-responsive projects.
  • The book ESP32 Smart Home covers daylight harvesting (automatic blinds) using the BH1750.

Chapter 27

ESP32: read a BME280 temperature, humidity, and pressure sensor

esp32 · 25 min

The BME280 is the sensor I reach for when a DHT22 is not enough. It reads temperature, humidity, and barometric pressure. It uses I2C (two wires). It is accurate to about 1 hPa on pressure, which is enough to detect weather changes and altitude shifts of a few meters. The DHT22 only does temperature and humidity, and it is slow and finicky.

This tutorial gets you from a fresh ESP32 to a clean weather reading in about 25 minutes.

What you need

  • ESP32 dev board
  • BME280 breakout board (the Adafruit or SparkFun ones are plug-and-play; the cheap AliExpress ones usually work too but check the chip markings)
  • 4 jumper wires

Wiring (I2C)

The BME280 uses I2C. On most ESP32 dev boards, the default I2C pins are GPIO 21 (SDA) and GPIO 22 (SCL). That is what we use here.

BME280 VCC -- ESP32 3.3V (do NOT use 5V, the BME280 is 3.3V)
BME280 GND -- ESP32 GND
BME280 SDA -- ESP32 GPIO 21
BME280 SCL -- ESP32 GPIO 22

That is the entire wiring.

Some BME280 breakouts have an SDO pin. If yours does, it usually controls the I2C address. SDO to GND = address 0x76. SDO to VCC = address 0x77. Most boards default to 0x76. If the scanner does not find the chip, try the other address.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for Adafruit BME280. Install it. Also install Adafruit Unified Sensor when prompted (it is a dependency).

The code

ESP32 (Arduino)

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

void setup() {
  Serial.begin(115200);
  delay(1000);
  Wire.begin();

  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    while (1);
  }
  Serial.println("BME280 found.");
}

void loop() {
  Serial.print(bme.readTemperature());
  Serial.print(", ");
  Serial.print(bme.readHumidity());
  Serial.print(", ");
  Serial.println(bme.readPressure() / 100.0);
  delay(2000);
}

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

void setup() {
  Serial.begin(9600);
  delay(1000);
  Wire.begin();

  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    while (1);
  }
  Serial.println("BME280 found.");
}

void loop() {
  Serial.print(bme.readTemperature());
  Serial.print(", ");
  Serial.print(bme.readHumidity());
  Serial.print(", ");
  Serial.println(bme.readPressure() / 100.0);
  delay(2000);
}

I2C on the Uno uses A4 (SDA) and A5 (SCL). The ESP32 uses GPIO 21/22. Wire.begin() picks the right pins per board.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import time

# ESP32 default I2C: GPIO 21 (SDA), 22 (SCL)
# Pico default I2C: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)
devices = i2c.scan()
print(f'I2C devices: {[hex(d) for d in devices]}')

BME280_ADDR = 0x76

def read_bme280():
    # Burst-read 8 bytes: press(3) + temp(3) + hum(2)
    data = i2c.readfrom_mem(BME280_ADDR, 0xF7, 8)
    press_raw = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
    temp_raw = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4)
    hum_raw = (data[6] << 8) | data[7]
    # Rough conversion (no calibration compensation):
    temp_c = temp_raw / 5120.0
    hum_pct = hum_raw / 1024.0
    press_hpa = press_raw / 256.0 / 100.0
    return temp_c, hum_pct, press_hpa

print('BME280 reading (raw, not calibrated)')
while True:
    t, h, p = read_bme280()
    print(f'{t:.2f} C, {h:.2f} %, {p:.2f} hPa')
    time.sleep(2)

For calibrated readings on MicroPython, install the official BME280 driver: mip install bme280 (on the Pico W with mip) or copy bme280.py to the ESP32's filesystem.

Raspberry Pi Python

import smbus2
import time

bus = smbus2.SMBus(1)
BME280_ADDR = 0x76

# Calibration registers (truncated; full driver is at
# https://github.com/pimoroni/bme280-python)
cal = bus.read_i2c_block_data(BME280_ADDR, 0x88, 26)
dig_T = (cal[0] | cal[1] << 8) / 16384.0, (cal[2] | cal[3] << 8) / 1024.0

def read_raw():
    bus.write_i2c_block_data(BME280_ADDR, 0xF4, [0x25])   # press + temp
    time.sleep(0.1)
    bus.write_i2c_block_data(BME280_ADDR, 0xF2, [0x01])   # humidity
    time.sleep(0.1)
    data = bus.read_i2c_block_data(BME280_ADDR, 0xF7, 8)
    return (data[0] << 12) | (data[1] << 4) | (data[2] >> 4), \
           (data[3] << 12) | (data[4] << 4) | (data[5] >> 4), \
           (data[6] << 8) | data[7]

print('BME280 ready')
while True:
    t_raw, p_raw, h_raw = read_raw()
    print(f'{t_raw} T, {p_raw} P, {h_raw} H (raw)')
    time.sleep(2)

For real Pi projects, install the proper driver:

pip3 install bme280

It handles all the calibration math.

What you should see

Why use BME280 instead of DHT22

The BME280 is better than the DHT22 for almost everything:

Sensor Temperature Humidity Pressure Accuracy Speed Cost
DHT22 yes yes no ±0.5 C, ±2-5% RH 1 read / 2 sec ~$2
BME280 yes yes yes ±1 C, ±3% RH, ±1 hPa 100+ reads / sec ~$5

The BME280 is faster, more accurate, and gives you pressure. The only reason to pick a DHT22 is if you have one lying around or you need a sensor that runs on long wires (the DHT22's protocol tolerates longer runs than I2C).

Reading pressure as altitude

Barometric pressure changes with both weather and altitude. To use pressure as an altimeter, you need to know the sea-level reference pressure for your location on the day you calibrate. Then:

float readAltitude(float seaLevelhPa) {
  float pressure = bme.readPressure() / 100.0;
  return 44330.0 * (1.0 - pow(pressure / seaLevelhPa, 0.1903));
}

void loop() {
  Serial.print(readAltitude(1013.25));   // adjust to your location
  Serial.println(" m");
  delay(1000);
}

This gives you altitude in meters above sea level. With a sea-level reference of 1013.25 hPa, accuracy is about ±10 m. With a known reference (e.g. you are at sea level), it is ±1 m.

Weather changes the sea-level reference by about ±25 hPa. That is about ±200 m of apparent altitude change. For an indoor altimeter, it is fine. For outdoor use, you need a weather-corrected reference.

Using multiple BME280s on one I2C bus

The BME280's I2C address is 0x76 or 0x77, selectable with the SDO pin. That means you can put two BME280s on one I2C bus, but not more.

If you need more sensors, use a different I2C bus (the ESP32 has two) or a multiplexer like the TCA9548A. For most projects (one indoor sensor, one outdoor sensor), two on one bus is enough.

Adafruit_BME280 bmeIndoor;
Adafruit_BME280 bmeOutdoor;

void setup() {
  Wire.begin();
  bmeIndoor.begin(0x76);
  bmeOutdoor.begin(0x77);   // SDO tied to VCC on this one
}

void loop() {
  Serial.print(bmeIndoor.readTemperature());
  Serial.print(", ");
  Serial.print(bmeOutdoor.readTemperature());
  Serial.println();
  delay(2000);
}

Sampling rate and power

The BME280 defaults to "normal" mode: one sample, sleep, repeat on request. For higher sample rates (e.g. logging weather changes), set the mode:

bme.setSampling(Adafruit_BME280::MODE_NORMAL,
                Adafruit_BME280::SAMPLING_X2,    // temperature oversampling
                Adafruit_BME280::SAMPLING_X16,   // humidity oversampling
                Adafruit_BME280::SAMPLING_X8,    // pressure oversampling
                Adafruit_BME280::FILTER_OFF,
                Adafruit_BME280::STANDBY_MS_1000);

Higher oversampling = more accurate but slower. The defaults are fine for most projects. For weather logging, the X16 humidity oversampling makes a noticeable difference in noisy conditions.

Logging to MQTT

The natural next step is publishing readings over MQTT. Combine this tutorial with the ESP32 MQTT publish/subscribe tutorial:

#include <PubSubClient.h>
#include <WiFi.h>

WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

void setup() {
  Serial.begin(115200);
  Wire.begin();
  bme.begin(0x76);

  WiFi.begin("your-ssid", "your-password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  mqtt.setServer("192.168.1.50", 1883);
  mqtt.connect("esp32-bme280");
}

unsigned long lastPublish = 0;

void loop() {
  if (millis() - lastPublish > 30000) {
    lastPublish = millis();
    char msg[80];
    snprintf(msg, sizeof(msg),
      "{\"temp\":%.2f,\"hum\":%.2f,\"press\":%.2f}",
      bme.readTemperature(),
      bme.readHumidity(),
      bme.readPressure() / 100.0);
    mqtt.publish("ctrlaltbrian/sensor/weather", msg);
  }
  delay(100);
}

This publishes JSON-formatted weather data every 30 seconds to your MQTT broker. The book IoT with ESP32 has a complete home sensor network using this pattern.

What you learned

  • BME280 reads temperature, humidity, and pressure over I2C.
  • Wiring is 4 wires (VCC, GND, SDA, SCL). Most breakouts use I2C address 0x76.
  • The library is Adafruit BME280 + Adafruit Unified Sensor.
  • BME280 is the right pick over DHT22 for any project where accuracy or speed matter.

When something breaks

  • "Could not find BME280". Wrong address (try 0x77), wrong wiring, wrong voltage (BME280 is 3.3V only, not 5V).
  • Pressure reading seems wrong. Probably fine; 1013 hPa is "average" sea-level pressure, but local weather changes it ±25 hPa.
  • Humidity reading saturates at 100%. The sensor is condensing. Move it away from the source of moisture, or reduce sampling rate.
  • Temperature reads higher than expected. Self-heating from the ESP32. Move the sensor away from the chip, or add delay() between reads.

What to build next

  • The ESP32 MQTT publish/subscribe tutorial publishes these readings to a broker.
  • The ESP32 deep sleep tutorial uses BME280 as the wake trigger (only wake up every 5 minutes to publish).
  • The Raspberry Pi Node-RED tutorial builds a dashboard that consumes these MQTT readings and graphs them.

Chapter 28

ESP32: read a DS18B20 temperature sensor with OneWire

esp32 · 25 min

The DS18B20 is the temperature sensor I default to when I want multiple sensors on one wire, or when I need accuracy better than ±0.5 C, or when the sensor is more than a meter away from the microcontroller. It uses Dallas Semiconductor's OneWire protocol, which lets you put dozens of sensors on a single GPIO pin and read each one individually.

This tutorial covers the wiring, the library setup, reading one sensor, and reading multiple sensors on one pin.

What you need

  • ESP32 dev board
  • One or more DS18B20 sensors (the bare TO-92 package, or the waterproof stainless steel probe version)
  • 4.7k ohm resistor (for the pull-up)
  • Jumper wires

The waterproof DS18B20 probe comes pre-wired with red (VCC), black (GND), and yellow (data). It is about $3 and is what I use for any project where the sensor is more than a few centimeters from the board.

Wiring

DS18B20 GND -- ESP32 GND
DS18B20 DATA -- ESP32 GPIO 4 --[ 4.7k pull-up ]-- ESP32 3.3V
DS18B20 VCC -- ESP32 3.3V

The pull-up resistor is mandatory. Without it, the OneWire bus does not work. The data line floats when no sensor is driving it, and the protocol relies on the pull-up to hold the line HIGH between transactions.

For multiple DS18B20s on one pin, just connect all the data lines to the same GPIO pin. Each DS18B20 has a unique 64-bit address burned into it during manufacturing. The library uses the address to talk to each sensor individually.

DS18B20 #1 DATA ---+
DS18B20 #2 DATA ---+--- ESP32 GPIO 4 --[ 4.7k pull-up ]-- ESP32 3.3V
DS18B20 #3 DATA ---+

You can put dozens of sensors on one pin. The bus is address-based, not position-based, so order does not matter.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for OneWire by Paul Stoffregen. Install it. Also install DallasTemperature by Miles Burton.

The code

ESP32 (Arduino)

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_PIN 4

OneWire oneWire(ONE_WIRE_PIN);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(115200);
  delay(1000);
  sensors.begin();
}

void loop() {
  sensors.requestTemperatures();
  float tempC = sensors.getTempCByIndex(0);
  if (tempC == -127.0) {
    Serial.println("Failed to read DS18B20");
  } else {
    Serial.print("Temperature: ");
    Serial.print(tempC);
    Serial.println(" C");
  }
  delay(2000);
}

Arduino (Uno, Nano, Mega)

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_PIN 2   // any digital pin works; pin 2 is convenient on Uno

OneWire oneWire(ONE_WIRE_PIN);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(9600);
  delay(1000);
  sensors.begin();
}

void loop() {
  sensors.requestTemperatures();
  float tempC = sensors.getTempCByIndex(0);
  if (tempC == -127.0) {
    Serial.println("Failed to read DS18B20");
  } else {
    Serial.print("Temperature: ");
    Serial.print(tempC);
    Serial.println(" C");
  }
  delay(2000);
}

The Uno has less RAM than the ESP32 (2KB vs 320KB). If you have many sensors (more than 10), drop the resolution to 9 bits to save RAM.

MicroPython (ESP32 or Pico)

from machine import Pin
import onewire
import ds18x20
import time

# ESP32: any GPIO; pin 4 is safe
# Pico: any GPIO; pin 4 is safe
ow = onewire.OneWire(Pin(4))
sensors = ds18x20.DS18X20(ow)

roms = sensors.scan()
print(f'Found {len(roms)} sensor(s)')

while True:
    sensors.convert_temp()
    time.sleep_ms(750)   # 12-bit resolution conversion time
    for rom in roms:
        temp = sensors.read_temp(rom)
        print(f'Sensor {rom.hex()}: {temp:.2f} C')
    time.sleep(2)

Raspberry Pi Python (with kernel OneWire driver)

The Pi has a built-in OneWire driver that exposes DS18B20 sensors through sysfs. Enable it once:

sudo raspi-config >> Interface Options >> 1-Wire >> Enable

Then in Python:

import glob
import time

# The kernel driver creates /sys/bus/w1/devices/28-*/w1_slave entries
# for each DS18B20 found. 28- is the family code.
base = '/sys/bus/w1/devices/'
sensors = sorted(glob.glob(base + '28-*'))
print(f'Found {len(sensors)} sensor(s)')

def read(path):
    with open(path + '/w1_slave') as f:
        lines = f.readlines()
    if lines[0].strip()[-3:] != 'YES':
        return None
    t_pos = lines[1].find('t=')
    if t_pos == -1:
        return None
    return int(lines[1][t_pos+2:]) / 1000.0

while True:
    for s in sensors:
        print(f'{s.split("/")[-1]}: {read(s)} C')
    time.sleep(2)

This pattern works for both bare DS18B20 sensors and the waterproof probe, on any Raspberry Pi with the kernel driver enabled.

What you should see

Upload. Open Serial Monitor at 115200 baud. You should see:

Temperature: 23.31 C
Temperature: 23.31 C
Temperature: 23.30 C

If every read returns -127.00, the wiring is wrong. Check the pull-up resistor first.

Reading multiple sensors by index

The getTempCByIndex(0) reads the first sensor. getTempCByIndex(1) reads the second. For a small number of sensors (under 10), this works:

void loop() {
  sensors.requestTemperatures();
  Serial.print("Sensor 0: ");
  Serial.print(sensors.getTempCByIndex(0));
  Serial.print(" C  Sensor 1: ");
  Serial.print(sensors.getTempCByIndex(1));
  Serial.print(" C  Sensor 2: ");
  Serial.print(sensors.getTempCByIndex(2));
  Serial.println(" C");
  delay(2000);
}

The order of indices depends on the order the sensors respond on the bus. It can change if you add or remove sensors. For stable addressing, use the address-based read below.

Reading multiple sensors by address

First, find the addresses:

void setup() {
  Serial.begin(115200);
  delay(1000);
  sensors.begin();

  int count = sensors.getDeviceCount();
  Serial.print("Found ");
  Serial.print(count);
  Serial.println(" sensors.");

  for (int i = 0; i < count; i++) {
    DeviceAddress addr;
    sensors.getAddress(addr, i);
    Serial.print("Sensor ");
    Serial.print(i);
    Serial.print(": ");
    for (int j = 0; j < 8; j++) {
      if (addr[j] < 16) Serial.print("0");
      Serial.print(addr[j], HEX);
    }
    Serial.println();
  }
}

Run this once, copy the addresses from the Serial Monitor, then write the addresses as constants:

DeviceAddress livingRoom = {0x28, 0xFF, 0x64, 0x1E, 0xC2, 0x00, 0x00, 0x9A};
DeviceAddress kitchen    = {0x28, 0xFF, 0x57, 0x32, 0xC2, 0x00, 0x00, 0x4D};

void loop() {
  sensors.requestTemperatures();
  Serial.print("Living room: ");
  Serial.print(sensors.getTempC(livingRoom));
  Serial.print(" C  Kitchen: ");
  Serial.print(sensors.getTempC(kitchen));
  Serial.println(" C");
  delay(2000);
}

Address-based reads do not depend on bus order. You can add or remove sensors without breaking the others.

The "device count is 0" bug

The most common DS18B20 problem is that sensors.getDeviceCount() returns 0. This means the library cannot find any sensor on the bus.

Causes:

  • Pull-up resistor missing or wrong value (4.7k is the standard pick).
  • Wiring reversed on VCC and GND. The DS18B20 has them swapped compared to many sensors; check the pinout.
  • Data line is on GPIO 0, GPIO 2, or another boot pin. Use GPIO 4 or another general-purpose pin.
  • 5V power on a 3.3V DS18B20 (some variants are 5V-tolerant; check the datasheet for your specific part).

The parasitic power mode

There is a variant of DS18B20 wiring called "parasitic power" where the sensor draws its power from the data line instead of a separate VCC wire. Wire it like this:

DS18B20 GND -- ESP32 GND
DS18B20 DATA -- ESP32 GPIO 4 --[ 4.7k pull-up ]-- ESP32 3.3V
DS18B20 VCC -- ESP32 GND   (yes, VCC and GND both go to GND)

In code:

sensors.setWaitForConversion(false);

Parasitic power saves one wire. It is finicky for long wire runs (the parasitic capacitance gets too high) but works fine for under 3 m. For anything longer, use the normal wiring.

Resolution and conversion time

The DS18B20 supports 9-bit to 12-bit resolution. Higher resolution = more accurate but slower.

sensors.setResolution(12);   // 0.0625 C, 750 ms conversion time
sensors.setResolution(9);    // 0.5 C, 94 ms conversion time

For a slow-changing indoor temperature sensor, 12-bit is fine. For a fast-moving sensor (e.g. measuring water flow), 9 or 10-bit gives you faster readings at the cost of resolution.

What you learned

  • DS18B20 uses OneWire: one data wire for one or many sensors.
  • Each sensor has a unique 64-bit address. The library uses addresses to identify each sensor.
  • The 4.7k pull-up resistor is mandatory.
  • 0.5 C accuracy, 0.0625 C resolution, range -55 to 125 C.

When something breaks

  • Every read returns -127. Pull-up missing, wrong value, or wiring reversed. The -127 is the library's "no response" code.
  • First read works, second fails. Power issue. Add a capacitor across VCC and GND at the sensor (10-100 uF).
  • Readings are off by a few degrees. You have a counterfeit sensor. Yes, this happens. The genuine DS18B20 has a recognizable ROM signature; fakes do not. Buy from a reputable source.
  • Bus gets stuck after a few hours. Add sensors.setWaitForConversion(true) and ensure the conversion time is appropriate for your resolution.

What to build next

  • The BME280 tutorial reads temperature, humidity, and pressure in one chip. Compare to DS18B20 for indoor use.
  • The deep sleep tutorial uses DS18B20 as a wake source: only wake the ESP32 every 5 minutes to read and publish.
  • The book ESP32 in Production covers long-wire runs (up to 100 m on twisted pair), multiple buses, and proper grounding for industrial deployments.

Chapter 29

ESP32: read an MPU6050 accelerometer and gyroscope over I2C

esp32 · 30 min

The MPU6050 is the IMU (inertial measurement unit) I reach for when I need to know how something is moving or rotating. It has a 3-axis accelerometer (gravity and motion) and a 3-axis gyroscope (rotation rate), all on one chip over I2C. It is the sensor at the heart of most robotics projects, drones, motion controllers, and self-balancing contraptions.

This tutorial covers the wiring, the library, reading raw acceleration and rotation, and the orientation-from-gravity trick that lets you detect tilt without any math you did not write yourself.

What you need

  • ESP32 dev board
  • MPU6050 breakout board (the GY-521 is the most common; $2 from anywhere)
  • 4 jumper wires

Wiring (I2C)

The MPU6050 uses I2C. Same pins as the BME280:

MPU6050 VCC -- ESP32 5V (the GY-521 has a 3.3V regulator onboard)
MPU6050 GND -- ESP32 GND
MPU6050 SDA -- ESP32 GPIO 21
MPU6050 SCL -- ESP32 GPIO 22

The GY-521 breakout has a 3.3V regulator on it. You can power it from 3.3V or 5V; either works. Most projects use 5V because it is easier to find.

If your MPU6050 has an AD0 pin, it controls the I2C address. AD0 to GND = 0x68 (default). AD0 to VCC = 0x69. The library defaults to 0x68.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for Adafruit MPU6050. Install it. Also install Adafruit Unified Sensor and Adafruit BusIO when prompted.

The code

ESP32 (Arduino)

#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

Adafruit_MPU6050 mpu;

void setup() {
  Serial.begin(115200);
  delay(1000);
  Wire.begin();
  if (!mpu.begin()) {
    Serial.println("Could not find MPU6050");
    while (1);
  }
  mpu.setAccelerometerRange(MPU6050_RANGE_2_G);
  mpu.setGyroRange(MPU6050_RANGE_250_DEG);
  mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
}

void loop() {
  sensors_event_t a, g, temp;
  mpu.getEvent(&a, &g, &temp);
  Serial.print("Accel X: ");
  Serial.print(a.acceleration.x);
  Serial.print("  Y: ");
  Serial.print(a.acceleration.y);
  Serial.print("  Z: ");
  Serial.print(a.acceleration.z);
  Serial.print("  |  Gyro X: ");
  Serial.print(g.gyro.x);
  Serial.print("  Y: ");
  Serial.print(g.gyro.y);
  Serial.print("  Z: ");
  Serial.print(g.gyro.z);
  Serial.println();
  delay(100);
}

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

Adafruit_MPU6050 mpu;

void setup() {
  Serial.begin(9600);
  delay(1000);
  Wire.begin();
  if (!mpu.begin()) {
    Serial.println("Could not find MPU6050");
    while (1);
  }
  mpu.setAccelerometerRange(MPU6050_RANGE_2_G);
  mpu.setGyroRange(MPU6050_RANGE_250_DEG);
  mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
}

void loop() {
  sensors_event_t a, g, temp;
  mpu.getEvent(&a, &g, &temp);
  Serial.print("Accel X: ");
  Serial.print(a.acceleration.x);
  Serial.print("  Y: ");
  Serial.print(a.acceleration.y);
  Serial.print("  Z: ");
  Serial.print(a.acceleration.z);
  Serial.print("  |  Gyro X: ");
  Serial.print(g.gyro.x);
  Serial.print("  Y: ");
  Serial.print(g.gyro.y);
  Serial.print("  Z: ");
  Serial.print(g.gyro.z);
  Serial.println();
  delay(100);
}

The MPU6050 library allocates about 700 bytes of RAM for the sensor struct. The Uno's 2KB is tight; use F() macro on your Serial prints to save another 100-200 bytes.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import time

# ESP32 default I2C: GPIO 21 (SDA), 22 (SCL)
# Pico default I2C: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000)
devices = i2c.scan()
print(f'I2C devices: {[hex(d) for d in devices]}')

MPU6050_ADDR = 0x68

# Wake up the MPU6050 (it starts in sleep mode)
i2c.writeto_mem(MPU6050_ADDR, 0x6B, b'\x00')
time.sleep_ms(100)

def read_word(reg):
    data = i2c.readfrom_mem(MPU6050_ADDR, reg, 2)
    v = (data[0] << 8) | data[1]
    return v - 65536 if v >= 32768 else v

while True:
    ax = read_word(0x3B) / 16384.0 * 9.81   # m/s^2
    ay = read_word(0x3D) / 16384.0 * 9.81
    az = read_word(0x3F) / 16384.0 * 9.81
    gx = read_word(0x43) / 131.0 * 0.01745   # rad/s
    gy = read_word(0x45) / 131.0 * 0.01745
    gz = read_word(0x47) / 131.0 * 0.01745
    print(f'Ax: {ax:+.2f} Ay: {ay:+.2f} Az: {az:+.2f}  Gx: {gx:+.2f} Gy: {gy:+.2f} Gz: {gz:+.2f}')
    time.sleep(0.1)

For MicroPython projects with sensor fusion (the complementary filter or Madgwick), install imu.py from https://github.com/micropython-IMU/micropython-imu.

Raspberry Pi Python

import smbus2
import time

bus = smbus2.SMBus(1)
MPU6050_ADDR = 0x68

bus.write_byte_data(MPU6050_ADDR, 0x6B, 0)   # wake up
time.sleep(0.1)

def read_word(reg):
    high = bus.read_byte_data(MPU6050_ADDR, reg)
    low = bus.read_byte_data(MPU6050_ADDR, reg + 1)
    v = (high << 8) + low
    return v - 65536 if v >= 32768 else v

while True:
    ax = read_word(0x3B) / 16384.0 * 9.81
    ay = read_word(0x3D) / 16384.0 * 9.81
    az = read_word(0x3F) / 16384.0 * 9.81
    print(f'Ax: {ax:+.2f} Ay: {ay:+.2f} Az: {az:+.2f}')
    time.sleep(0.1)

What you should see

Upload. Open Serial Monitor at 115200 baud. Move the sensor around. You should see the acceleration values change in m/s^2 and the gyroscope values change in rad/s.

When the sensor is sitting still on a flat surface:

  • Accel Z reads about +9.8 (gravity)
  • Accel X and Y read close to 0
  • Gyro X, Y, Z read close to 0

When you tilt the sensor, the accel values redistribute. When you spin it, the gyro values spike.

The two output units

The library gives you two units to choose from:

  • a.acceleration.x in m/s^2 (what the code above uses)
  • mpu.getAccelerationX() in g (1g = 9.8 m/s^2)

I prefer m/s^2 because it works with the standard gravity constant. For projects that care about "how many g's of force am I seeing", use the getter variant.

Detecting tilt from accelerometer only

Without any math you did not write, the accelerometer tells you the sensor's orientation relative to gravity. The trick:

void loop() {
  sensors_event_t a, g, temp;
  mpu.getEvent(&a, &g, &temp);

  // pitch and roll from gravity vector
  float pitch = atan2(-a.acceleration.x, sqrt(a.acceleration.y * a.acceleration.y +
                                                a.acceleration.z * a.acceleration.z)) * 180.0 / PI;
  float roll  = atan2(a.acceleration.y, a.acceleration.z) * 180.0 / PI;

  Serial.print("Pitch: ");
  Serial.print(pitch);
  Serial.print(" deg  Roll: ");
  Serial.print(roll);
  Serial.println(" deg");
  delay(100);
}

Tilt the sensor forward: pitch changes. Tilt right: roll changes. This is the orientation read that most projects need.

The accelerometer-based tilt is accurate when the sensor is not accelerating. If the sensor is moving, the accel values include both gravity and motion. For tilt during movement, you need a sensor fusion algorithm (complementary filter, Kalman filter). The book ESP32 Robotics Projects covers the complementary filter in depth.

Reading rotation rate (the gyro)

The gyroscope measures rotation rate, not angle. To get total rotation angle, integrate:

unsigned long lastUpdate = 0;
float yawAngle = 0;

void loop() {
  sensors_event_t a, g, temp;
  mpu.getEvent(&a, &g, &temp);

  unsigned long now = millis();
  float dt = (now - lastUpdate) / 1000.0;
  lastUpdate = now;

  // gyro Z is the rotation around the Z axis (yaw)
  yawAngle += g.gyro.z * dt;   // rad/s * s = rad

  Serial.print("Yaw: ");
  Serial.print(yawAngle * 180.0 / PI);
  Serial.println(" deg");
  delay(50);
}

The result is yaw angle in degrees. The longer you run, the more the angle drifts (gyro drift is a real thing). For projects that need accurate rotation tracking, fuse the gyro with the accelerometer using a complementary filter.

The I2C address trick

The default address is 0x68. If you want two MPU6050s on one I2C bus (rare but possible for stereo motion tracking), set the AD0 pin on the second one to VCC and use address 0x69:

Adafruit_MPU6050 mpu1;   // 0x68
Adafruit_MPU6050 mpu2;   // 0x69

void setup() {
  Wire.begin();
  mpu1.begin();
  mpu2.begin(0x69);
}

For more than two MPU6050s, you need an I2C multiplexer (TCA9548A).

Range settings

The MPU6050 supports multiple ranges:

mpu.setAccelerometerRange(MPU6050_RANGE_2_G);   // +/- 2g (default)
mpu.setAccelerometerRange(MPU6050_RANGE_4_G);   // +/- 4g
mpu.setAccelerometerRange(MPU6050_RANGE_8_G);   // +/- 8g
mpu.setAccelerometerRange(MPU6050_RANGE_16_G);  // +/- 16g

mpu.setGyroRange(MPU6050_RANGE_250_DEG);   // +/- 250 deg/s
mpu.setGyroRange(MPU6050_RANGE_500_DEG);   // +/- 500 deg/s
mpu.setGyroRange(MPU6050_RANGE_1000_DEG);  // +/- 1000 deg/s
mpu.setGyroRange(MPU6050_RANGE_2000_DEG);  // +/- 2000 deg/s

Smaller range = more precision. Larger range = handles more violent motion. For a self-balancing robot, +/- 2g accel and +/- 500 deg/s gyro is fine. For a drone or a crash-prone project, +/- 16g and +/- 2000 deg/s.

Filter bandwidth

The MPU6050 has a built-in low-pass filter. Setting the bandwidth filters out high-frequency noise (vibration, electrical interference):

mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);   // default

Options: 21, 44, 94, 184, 260 Hz. Lower = more filtering but slower response. For most projects, 21 or 44 Hz is right. For a high-speed robot, 94 or 184 Hz.

What you learned

  • MPU6050 reads 3-axis acceleration (m/s^2) and 3-axis rotation (rad/s) over I2C.
  • Wiring is 4 wires. The breakout board has a 3.3V regulator so 5V or 3.3V both work.
  • Tilt (pitch and roll) can be derived from the accelerometer alone when the sensor is stationary.
  • Gyro integration gives you rotation angle, but it drifts. Fuse with accel for accurate orientation.

When something breaks

  • "Could not find MPU6050". Wrong address (try 0x69), wrong wiring, bad solder joint on the breakout.
  • Readings are noisy. Filter bandwidth too high. Set to 21 Hz.
  • Tilt reads correctly when stationary, wrong when moving. You are using accel-only orientation. Need sensor fusion.
  • Gyro drifts over time. That is the MPU6050; it is not broken. Use sensor fusion to correct.

What to build next

  • The HC-SR04 ultrasonic tutorial is the other sensor most robots use (distance sensing). Combine with MPU6050 for obstacle-avoiding robots.
  • The ESP32 servo tutorial uses MPU6050 as feedback: a camera gimbal that stays level as the base moves.
  • The book ESP32 Robotics Projects covers sensor fusion (complementary filter, Madgwick, Mahony) in depth.

Chapter 30

ESP32: GPIO basics, digital read, digital write, and the pull-up trick

esp32 · 20 min

GPIO is the part of the ESP32 you spend 90% of your time on. Read a button, light an LED, drive a relay, talk to a sensor. All GPIO. Get this foundation right and every project gets easier. Get it wrong and you spend a Saturday debugging a floating pin that is reading random noise.

This tutorial covers the basics: digital write (output), digital read (input), the internal pull-up trick that lets you wire a button with no resistors, and the four pins that are input-only and cannot do output.

What you need

  • An ESP32 dev board
  • An LED and a 220 ohm resistor (any color)
  • A momentary pushbutton (the four-leg tactile kind)
  • Three jumper wires
  • The toolchain install from the previous tutorial

The GPIO map you need to memorize

The ESP32 has 34 GPIO pins, but not all of them are usable for general work.

GPIO range Notes
GPIO 0 Boot pin. Avoid unless you know why.
GPIO 1, 3 Used for serial debug. Avoid.
GPIO 2 Boot pin, often has the onboard LED.
GPIO 4-5 General purpose. Safe.
GPIO 6-11 Connected to internal flash. Never use these.
GPIO 12-15 Boot pins. General purpose is OK if you are careful about boot state.
GPIO 16-33 General purpose. Safe.
GPIO 34, 35, 36, 39 Input only. Cannot drive output.
GPIO 37, 38 Not exposed on most dev boards.

The pins that say "Never use these" (GPIO 6-11) are physically connected to the ESP32's flash chip. Using them as GPIO crashes the program.

The pins that are input-only (GPIO 34-39, minus 37-38 which are not exposed) cannot be set as outputs. The IDE silently ignores pinMode() on them if you ask for OUTPUT. The fix is to pick a different pin.

The exact safe-pin list depends on your board. Most dev boards expose GPIO 4, 5, 13, 14, 16, 17, 18, 19, 21, 22, 23, 25, 26, 27, 32, 33. Use one of those for first projects. You will avoid every weird boot mode and every input-only trap.

Wiring

The blink circuit:

ESP32 GPIO 4  --[ 220 ohm ]-- LED anode (long leg) -- LED cathode (short leg) -- GND
ESP32 GPIO 5  --[ button ]-- GND  (yes, just one wire + the button)

That is the entire circuit. The button needs no resistor because of the internal pull-up (covered below).

The code: output

const int LED_PIN = 4;

void setup() {
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_PIN, HIGH);
  delay(500);
  digitalWrite(LED_PIN, LOW);
  delay(500);
}

Upload. The LED blinks at 1 Hz. HIGH is 3.3V, LOW is 0V. The 220 ohm resistor limits current to about 10 mA, which is bright enough for most LEDs and well within the ESP32's per-pin spec.

The current limit per GPIO is 40 mA absolute max, 20 mA recommended. Going over 20 mA for sustained periods damages the pin over time.

The code: input with internal pull-up

const int LED_PIN = 4;
const int BTN_PIN = 5;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);   // <-- the magic
}

void loop() {
  if (digitalRead(BTN_PIN) == LOW) {
    digitalWrite(LED_PIN, HIGH);
  } else {
    digitalWrite(LED_PIN, LOW);
  }
}

Upload. Press the button. The LED lights up. Release. The LED goes off.

The trick is INPUT_PULLUP. It enables the ESP32's internal pull-up resistor (about 45k ohms) on GPIO 5. This holds the pin HIGH when the button is not pressed. When you press the button, the pin connects to GND through the switch contacts, pulling it LOW.

Read that again. Pressed = LOW. Released = HIGH. This is the opposite of what feels natural. Most button tutorials on Arduino use the same convention for the same reason: it lets you skip the external resistor.

The reason pinMode(BTN_PIN, INPUT_PULLUP) works:

  • No external resistor needed. Saves wiring.
  • The pin cannot float (always reads HIGH or LOW, never random noise).
  • Pressed-vs-released is unambiguous.

The reason pinMode(BTN_PIN, INPUT) (no pull-up) does not work:

  • The pin is floating when the button is not pressed.
  • Reads random noise. Your code sees "pressed" when you did not press.
  • This is the bug that causes 80% of "my button does not work" complaints.

Always use INPUT_PULLUP for buttons unless you have a specific reason not to. Same applies to limit switches, reed switches, and most other momentary-contact inputs.

The code: debounce

The next bug you will hit is button bounce. The button makes and breaks contact a few times before settling. Your code sees multiple presses for one physical press. The fix is software debouncing.

The pattern I use in every project:

const int LED_PIN = 4;
const int BTN_PIN = 5;

enum ButtonState { IDLE, PRESSED, RELEASED };
ButtonState state = IDLE;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 30;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
  bool reading = digitalRead(BTN_PIN) == LOW;

  switch (state) {
    case IDLE:
      if (reading) {
        state = PRESSED;
        lastChange = millis();
      }
      break;
    case PRESSED:
      if (millis() - lastChange > DEBOUNCE_MS) {
        if (reading) {
          // confirmed press
          digitalWrite(LED_PIN, !digitalRead(LED_PIN));   // toggle
          state = RELEASED;
        } else {
          state = IDLE;
        }
      }
      break;
    case RELEASED:
      if (!reading) {
        state = IDLE;
        lastChange = millis();
      }
      break;
  }
}

Press the button. The LED toggles. Press it again. The LED toggles back. One press, one toggle, no bounce.

The pattern is a state machine. Three states:

  • IDLE: waiting for a press. When we see LOW, move to PRESSED.
  • PRESSED: waiting 30 ms for the bounce to settle. After that, if still LOW, it was a real press. Otherwise it was noise.
  • RELEASED: waiting for the button to be released before we accept another press.

30 ms is enough for most buttons. Cheaper buttons and membrane switches might need 50-100 ms.

Reading from input-only pins (GPIO 34-39)

If you need an analog sensor or a button on GPIO 34, the code is the same except you cannot drive them as output. pinMode(34, INPUT_PULLUP) works for reading.

void setup() {
  pinMode(34, INPUT);   // GPIO 34 has no internal pull-up
  // no INPUT_PULLUP option for input-only pins
}

void loop() {
  int v = digitalRead(34);
  // ...
}

GPIO 34-39 do not have internal pull-ups. If you wire a button to one of these, you need an external 10k pull-up resistor. Most of the time, just use GPIO 4 or another general-purpose pin instead.

What you learned

  • The ESP32 has 34 GPIO pins, but 6 of them (6-11) are off-limits, 4 (34-39 minus 37-38) are input-only, and 5 (0, 1, 2, 3, 12-15) have boot-mode caveats.
  • digitalWrite(pin, HIGH) sets a pin to 3.3V. LOW is 0V.
  • INPUT_PULLUP enables the internal pull-up resistor so you can wire a button with no external resistor.
  • Buttons need software debounce to work reliably. The state-machine pattern above is the simplest version that actually works.

When something breaks

  • Button reads as pressed when nothing is pressed. No pull-up. Add INPUT_PULLUP or wire an external 10k resistor.
  • Button reads as pressed every 2-3 presses. Debounce is too short for your button. Try 50 or 100 ms.
  • LED does not light. Check the wiring polarity. The LED's longer leg is the anode; that side goes to the GPIO side, not to GND.
  • Code does not compile. You used a GPIO number that does not exist (e.g. GPIO 37, 38 on a board that does not expose them). Check the silkscreen.

What to build next

  • The button debounce tutorial goes deeper on the state-machine pattern and shows you how to handle long-press vs short-press.
  • The analog ADC tutorial reads a potentiometer or analog sensor, which is the other half of GPIO (analog, not just digital).
  • The deep sleep tutorial uses wake-on-button as a way to save battery on projects that only need to run when you press something.

Chapter 31

ESP32: drive a piezo buzzer with tone()

esp32 · 20 min

The piezo buzzer is the cheapest output device: $1 and two wires. It plays a tone at whatever frequency you send. Use it for alarms, notifications, status beeps, or simple melodies.

This tutorial covers the wiring, the LEDC peripheral (which does this in hardware), and a pattern for playing tones from a melody table.

What you need

  • ESP32 dev board
  • Piezo buzzer (the active kind with a small oscillator on the back; about $1)
  • 2 jumper wires

The active buzzer has a small chip on the back and plays a tone when you apply power. The passive buzzer (no chip) needs a PWM signal to play a tone. Most cheap "buzzers" are active. For tone control, get the passive kind.

Wiring

Buzzer + (red) -- ESP32 GPIO 4
Buzzer - (black) -- ESP32 GND

That's it. The piezo draws about 10 mA, well within the ESP32's GPIO limits.

If the buzzer is loud, add a 100 ohm resistor in series with the + wire. This drops the volume to a less annoying level.

The code

ESP32 (Arduino)

const int BUZZER_PIN = 4;
const int BUZZER_CHANNEL = 0;

void setup() {
  ledcSetup(BUZZER_CHANNEL, 1000, 8);   // 1 kHz, 8-bit resolution
  ledcAttachPin(BUZZER_PIN, BUZZER_CHANNEL);
}

void loop() {
  // Play a melody
  int melody[] = {262, 294, 330, 349, 392, 440, 494, 523};   // C4 to C5
  for (int i = 0; i < 8; i++) {
    ledcWriteTone(BUZZER_CHANNEL, melody[i]);
    delay(300);
  }
  ledcWriteTone(BUZZER_CHANNEL, 0);   // silence
  delay(1000);
}

ledcWriteTone() is the LEDC's built-in tone generator. Pass a frequency in Hz, and the hardware produces that tone on the pin. Pass 0 to stop.

Arduino (Uno, Nano, Mega)

#include "pitches.h"

const int BUZZER_PIN = 4;

int melody[] = {NOTE_C4, NOTE_D4, NOTE_E4, NOTE_F4, NOTE_G4, NOTE_A4, NOTE_B4, NOTE_C5};
int noteDurations[] = {4, 4, 4, 4, 4, 4, 4, 4};

void setup() {
  for (int i = 0; i < 8; i++) {
    int duration = 1000 / noteDurations[i];
    tone(BUZZER_PIN, melody[i], duration);
    delay(duration * 1.3);   // 30% pause between notes
  }
  noTone(BUZZER_PIN);
}

void loop() {
}

Arduino's tone() uses Timer2 on most boards, which conflicts with analogWrite() on pins 3 and 11. Use a different pin if you also need PWM.

MicroPython (ESP32 or Pico)

from machine import Pin, PWM
import time

buzzer = PWM(Pin(4), freq=1000, duty=512)

melody = [262, 294, 330, 349, 392, 440, 494, 523]   # C4 to C5

while True:
    for freq in melody:
        buzzer.freq(freq)
        time.sleep_ms(300)
    buzzer.duty(0)   # silence
    time.sleep(1)

MicroPython's PWM duty cycle is 0-1023 on the ESP32 (10-bit). 512 is 50% duty. Set to 0 to silence. On the Pico, duty is 0-65535.

What you should see

Upload the ESP32 or Arduino version. The buzzer plays an ascending scale (C4 to C5), pauses, and repeats.

If the buzzer is silent, the active/passive distinction may be wrong. Active buzzers (with the oscillator chip on the back) only produce a single tone when powered. Passive buzzers (no chip) respond to PWM. Check the back of the buzzer for a small black blob; if present, it's active.

Note frequencies

The melody table uses standard note frequencies. Common reference:

Note Frequency
C4 262 Hz
D4 294 Hz
E4 330 Hz
F4 349 Hz
G4 392 Hz
A4 440 Hz
B4 494 Hz
C5 523 Hz

For Arduino, the pitches.h file (from the toneMelody example) has the full chromatic scale.

The alarm pattern

Most projects use the buzzer for alarms or alerts:

void alarm() {
  for (int i = 0; i < 5; i++) {
    ledcWriteTone(BUZZER_CHANNEL, 2000);   // high pitch
    delay(200);
    ledcWriteTone(BUZZER_CHANNEL, 0);     // silence
    delay(200);
  }
}

void setup() {
  ledcSetup(0, 2000, 8);
  ledcAttachPin(4, 0);
}

void loop() {
  alarm();
  delay(5000);
}

The pattern is short bursts of high-pitched tone, alternating with silence. This is what fire alarms and smoke detectors use because it is more attention-grabbing than a constant tone.

Volume control

For volume control, change the duty cycle:

ledcWrite(BUZZER_CHANNEL, 64);   // 25% duty = quieter
delay(1000);
ledcWrite(BUZZER_CHANNEL, 128);  // 50% duty = louder

At 0% duty, the buzzer is silent. At 50% duty, it is loudest for most piezos. Higher duty cycles can damage cheap piezos.

What you learned

  • The ESP32's LEDC peripheral generates tones in hardware.
  • Use ledcWriteTone(channel, frequency) for clean tone output.
  • Arduino's tone() works the same way, with Timer2.
  • MicroPython's PWM module can do this with freq() and duty().

When something breaks

  • Buzzer is silent. Wrong pin (active LOW buzzer needs active drive), or the buzzer is active and only outputs one tone.
  • Buzzer is constant, no tone variation. Active buzzer; replace with a passive one.
  • Buzzer is loud and tinny. Add a 100-ohm series resistor.
  • LEDC conflicts with another pin's PWM. Use a different LEDC channel number.

What to build next

  • The PIR motion tutorial combines with this for motion alarms.
  • The HC-SR04 tutorial adds a proximity-based alarm tone.
  • The book ESP32 Audio Projects covers MP3 playback from an SD card and WAV file output for richer sounds.

Chapter 32

ESP32: control a relay module for switching higher-voltage loads

esp32 · 25 min

The relay module is the most useful component for home automation. It lets your ESP32's 3.3V GPIO switch mains-voltage devices (120/240V AC) and high-current DC loads (12V motors, 24V LED strips). The relay's coil is driven by the ESP32; the switch contacts are isolated and can carry 10A or more.

This tutorial covers the wiring, the safety rules, and the code. The safety section is not optional. Mains voltage will kill you if you wire it wrong.

What you need

  • ESP32 dev board
  • 1-channel relay module (5V or 3.3V compatible; about $1-2)
  • Jumper wires

The single-channel relay modules are usually labeled:

  • VCC - relay coil power (5V on most modules; 3.3V on some)
  • GND - ground
  • IN or S - signal from the ESP32 GPIO

Use a relay module with opto-isolation. These have a separate power path for the relay coil and the input signal, with an optical isolator chip between them. They are safer than the non-isolated variants because a relay coil failure cannot damage your ESP32.

Wiring

Relay VCC -- ESP32 5V (the relay coil needs more power than the ESP32's 3.3V can supply)
Relay GND -- ESP32 GND
Relay IN  -- ESP32 GPIO 4

If your relay module is labeled "3.3V" on VCC, use the ESP32's 3.3V pin instead of 5V. Some cheap modules work on either.

Never power the relay from the ESP32's 3.3V pin if the coil draws more than ~50 mA. The 5V pin (USB-derived) is the right pick.

What you learned (read before the safety section)

The relay's "switch" side is completely isolated from the "control" side. The ESP32 controls a small coil; the coil's magnetic field moves a switch inside the relay. The switch contacts can carry mains voltage (120V or 240V AC) or low-voltage DC. The two sides do not electrically connect.

This is what makes relays useful. Your ESP32 stays safe at 3.3V while the load runs at any voltage the contacts are rated for.

Safety: the rules

  1. Wire mains voltage only when the circuit is unplugged. Turn off the breaker, not just the wall switch.
  2. Use wire nuts or Wago connectors, not solder, for mains joints. Solder joints on mains wiring can fail over time and arc.
  3. Use the correct wire gauge. 14 AWG for 15A circuits, 12 AWG for 20A. Lamp cords are 18 AWG and good for 5A.
  4. Keep mains wiring inside an enclosure. A project box, a junction box, or behind a switch plate. Never leave exposed mains terminals.
  5. Test with a low-voltage load first. Use a 12V light bulb or a battery-powered fan to verify the wiring before plugging in mains.

Mains voltage kills. If you have not worked with mains before, find someone who has and have them check your wiring before you plug it in. The book ESP32 Smart Home has a longer section on mains safety, including GFCI protection and code compliance.

The code

ESP32 (Arduino)

const int RELAY_PIN = 4;

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);   // relay off (active HIGH relay)
}

void loop() {
  digitalWrite(RELAY_PIN, HIGH);   // relay on
  delay(3000);
  digitalWrite(RELAY_PIN, LOW);    // relay off
  delay(3000);
}

Most relay modules are active LOW: the relay is on when the IN pin is LOW. If your relay turns on when you expect it off and vice versa, swap to digitalWrite(RELAY_PIN, !state).

Arduino (Uno, Nano, Mega)

const int RELAY_PIN = 4;

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);
}

void loop() {
  digitalWrite(RELAY_PIN, HIGH);
  delay(3000);
  digitalWrite(RELAY_PIN, LOW);
  delay(3000);
}

Same code. The Uno's 5V GPIO has no ADC2-vs-Wi-Fi trap, so any digital pin works.

Active HIGH vs Active LOW relay

Most cheap relay modules are active LOW: pulling the IN pin LOW turns the relay on. This is because the optocoupler's transistor turns on when current flows through it (which requires the IN pin to be LOW on most boards).

To check:

const int RELAY_PIN = 4;

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  Serial.begin(115200);
  Serial.println("Testing relay polarity");
  Serial.println("Relay should click in 5 seconds");
  delay(5000);
  digitalWrite(RELAY_PIN, HIGH);
  Serial.println("Pin HIGH. Listen for click.");
  delay(5000);
  digitalWrite(RELAY_PIN, LOW);
  Serial.println("Pin LOW. Listen for click.");
  delay(5000);
}

void loop() {}

When the relay clicks is your "on" state.

Switching a lamp (mains)

The standard home automation pattern:

  1. Cut the hot wire (black in the US, brown in the EU) of the lamp.
  2. Connect one end to the relay's COM (common) terminal.
  3. Connect the other end to the relay's NO (normally open) terminal.
  4. The neutral wire goes straight through (do not switch neutral).

When the relay activates, COM connects to NO and the lamp gets hot. When the relay deactivates, COM is open and the lamp is off.

The relay also has an NC (normally closed) terminal. This is the opposite: the circuit is closed when the relay is off. Useful for "fail-on" applications (e.g. a heater that should be on if the controller dies).

Switching a 12V LED strip

For low-voltage DC loads:

  1. Cut the +12V wire of the LED strip.
  2. Connect one end to COM, the other to NO.
  3. The relay's switch contacts handle the 12V; the coil side handles the 5V logic from the ESP32.

The relay contacts are rated for 10A at 125V AC or 10A at 28V DC. For higher current or voltage, use a contactor (a heavy-duty relay).

The contact bounce problem

Mechanical relay contacts bounce, just like buttons. For most projects (driving a lamp, a heater, a motor) this does not matter because the mechanical inertia of the load is slower than the bounce. For fast switching (e.g. PWM-like control of a heater), the bounce causes problems. Use a solid-state relay (SSR) instead.

What you learned

  • Relay modules are opto-isolated for safety.
  • Most modules are active LOW.
  • The control side is safe to wire from the ESP32.
  • The switch side can carry mains voltage with the right precautions.

When something breaks

  • Relay clicks but the load does not turn on. Load is wired to NC instead of NO (or vice versa). Swap the wire.
  • Relay never clicks. Wrong VCC (using 3.3V when the module needs 5V), or IN pin is wrong.
  • ESP32 resets when the relay clicks. Coil is drawing too much current. Add a separate 5V supply for the relay.
  • Relay stays on all the time. Wrong active level (HIGH vs LOW). Invert the logic.

What to build next

  • The PIR motion tutorial combines with this for motion-activated lighting.
  • The ESP32 MQTT tutorial lets you turn the relay on from your phone.
  • The book ESP32 Smart Home covers mains wiring in detail, with enclosure design and code compliance.

Chapter 33

ESP32: switch a high-current load with a 2N2222 transistor

esp32 · 20 min

The ESP32's GPIO pins can deliver about 40 mA each, 200 mA total across all pins. That is enough for LEDs and small signals, but not enough for pumps, fans, solenoids, or LED strips that draw amps. The 2N2222 NPN transistor lets the small GPIO current switch a much larger load current.

This tutorial covers the wiring, the math for choosing the base resistor, and the pattern for switching 12V loads from a 3.3V GPIO.

What you need

  • ESP32 dev board
  • 2N2222 NPN transistor (TO-92 package; about $0.10)
  • 1k ohm resistor (for the base)
  • Load: a 12V LED strip, 12V fan, or 12V pump (anything drawing under 800 mA)
  • 12V power supply for the load (the ESP32 cannot supply this)
  • Jumper wires

Wiring

ESP32 GPIO 4 --[ 1k resistor ]-- 2N2222 base (pin 1)
ESP32 GND     --                 2N2222 emitter (pin 3)
ESP32 GND     --                 12V supply GND  (common ground)
12V supply +  --                 Load +
Load -        --                 2N2222 collector (pin 2)

When the GPIO goes HIGH, current flows through the 1k resistor into the base, which turns on the transistor. Current flows from collector to emitter, which completes the circuit through the load. The load turns on.

When the GPIO goes LOW, the transistor turns off, and the load turns off.

The 2N2222's pinout (looking at the flat side): E B C (emitter, base, collector). The pin closest to the tab is the emitter. Pinout varies by manufacturer; check the datasheet for your specific part.

Why a transistor and not a relay

For DC loads (LEDs, fans, pumps), a transistor is better than a relay:

  • No moving parts. No clicks, no wear, no bounce.
  • Fast switching. PWM-friendly. You can dim an LED strip at 1 kHz.
  • Smaller. A 2N2222 is smaller than a relay module.
  • Cheaper. 10 cents vs a few dollars for the relay.
  • Quieter. No mechanical click.

The downsides:

  • Transistor gets hot. With a 12V load at 500 mA, the 2N2222 dissipates 6W as heat. That needs a heatsink.
  • No isolation. A short in the load can damage the ESP32.
  • DC only. Relays work for AC; transistors are DC-only.
  • Current limit. The 2N2222 is rated for 800 mA. For higher current, use a MOSFET.

For low-current DC loads (LEDs, small fans), the 2N2222 is the right pick. For high current or AC, use a relay.

The base resistor math

The transistor needs about 1/10th of the load current at the base to fully turn on. For a 500 mA load, the base needs 50 mA. The ESP32's GPIO delivers about 3.3V at 40 mA max, so we cannot supply 50 mA to the base. In practice, a smaller base current (10-20 mA) works for most loads because the transistor is well into saturation.

Calculate the base resistor:

R_base = (V_gpio - V_be) / I_base
R_base = (3.3V - 0.7V) / 0.01A = 260 ohms

Round up to 1k ohm (we want less base current, not more). 1k gives about 2.6 mA to the base, which is enough to switch 100-300 mA of load current with some saturation loss.

For higher load current (up to 800 mA), use a smaller base resistor (470 ohm or 220 ohm) to ensure full saturation:

R_base = (3.3V - 0.7V) / 0.02A = 130 ohms

Use 220 ohm for safety margin.

The code

ESP32 (Arduino)

const int LOAD_PIN = 4;

void setup() {
  pinMode(LOAD_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LOAD_PIN, HIGH);   // load on
  delay(3000);
  digitalWrite(LOAD_PIN, LOW);    // load off
  delay(3000);
}

Arduino (Uno, Nano, Mega)

const int LOAD_PIN = 4;

void setup() {
  pinMode(LOAD_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LOAD_PIN, HIGH);
  delay(3000);
  digitalWrite(LOAD_PIN, LOW);
  delay(3000);
}

Same code. The Uno's 5V GPIO delivers more base current than the ESP32's 3.3V, so the same base resistor value works for both.

PWM dimming an LED strip

The transistor is fast enough for PWM. Use analogWrite() (or LEDC on the ESP32) to dim the LED strip:

const int LOAD_PIN = 4;

void setup() {
  pinMode(LOAD_PIN, OUTPUT);
}

void loop() {
  for (int brightness = 0; brightness <= 255; brightness += 5) {
    analogWrite(LOAD_PIN, brightness);
    delay(50);
  }
  for (int brightness = 255; brightness >= 0; brightness -= 5) {
    analogWrite(LOAD_PIN, brightness);
    delay(50);
  }
}

This dims the LED strip from 0 to 100% and back. The 2N2222 handles PWM at 1 kHz fine.

For PWM at higher frequencies (above 10 kHz), use a MOSFET instead of a 2N2222. The 2N2222's switching speed is limited.

The flyback diode (for inductive loads)

If your load is inductive (a motor, a solenoid, a relay coil), the transistor needs a flyback diode across the load. The diode absorbs the back-EMF when the transistor switches off; without it, the back-EMF will damage the transistor.

Load + -- diode cathode (stripe)
Load - -- diode anode (no stripe)

Use a 1N4007 (for low-speed switching under 1 kHz) or a fast-recovery diode (for higher speeds). The 1N4007 is the standard pick.

When to use a MOSFET

The 2N2222 is rated for 800 mA and 40V. For higher current or voltage, use a MOSFET. The IRF520 is the standard pick for hobby projects: it handles 100V and 10A, and it has logic-level drive (works from 3.3V GPIO).

Wiring a MOSFET is the same as the 2N2222, but no base resistor is needed:

ESP32 GPIO 4 -- MOSFET gate
ESP32 GND -- MOSFET source
12V supply + -- Load +
Load - -- MOSFET drain

The IRF520's gate has a high capacitance that can cause voltage spikes. Add a 100 ohm resistor between the GPIO and the gate, and a 10k pull-down from gate to source to keep the MOSFET off when the GPIO is not initialized.

What you learned

  • The 2N2222 is the standard NPN transistor for switching small DC loads.
  • A 1k base resistor is enough for 100-300 mA loads.
  • Use a flyback diode for inductive loads (motors, solenoids).
  • Use a MOSFET for higher current or PWM at higher frequencies.

When something breaks

  • Load never turns on. Base resistor is wrong, or transistor is in backwards (swap emitter and collector).
  • Load is always on. Transistor is shorted, or GPIO is stuck HIGH.
  • Transistor gets very hot. Load is drawing too much current. Add a heatsink or use a MOSFET.
  • ESP32 resets when load turns off. Back-EMF from an inductive load. Add a flyback diode.

What to build next

  • The relay tutorial covers the alternative for AC or high-current loads.
  • The servo tutorial covers the alternative for precise position control.
  • The book ESP32 Robotics Projects covers H-bridge motor drivers for bidirectional motor control.

Chapter 34

ESP32: drive an SSD1306 OLED display over I2C

esp32 · 25 min

The SSD1306 OLED display is the upgrade path from the 16x2 LCD. Same I2C wiring (2 wires), but you get 128x64 pixels of graphics instead of 32 fixed characters. The text is sharper, the contrast is higher, and you can draw graphs, icons, and animation.

This tutorial covers the wiring, the library, drawing text and shapes, and the patterns for sensor dashboards.

What you need

  • ESP32 dev board
  • 128x64 SSD1306 OLED module (the kind with 4 pins: VCC, GND, SDA, SCL; about $3-5)
  • 4 jumper wires

Wiring

OLED VCC -- ESP32 3.3V (NOT 5V; SSD1306 is 3.3V)
OLED GND -- ESP32 GND
OLED SDA -- ESP32 GPIO 21
OLED SCL -- ESP32 GPIO 22

Some OLED modules have a voltage regulator on the back and accept 5V on VCC. Most do not. Check the back of your module. If the chip is directly on the PCB with no regulator, use 3.3V.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for Adafruit SSD1306. Install it. Also install Adafruit GFX Library when prompted (it is a dependency).

The code

ESP32 (Arduino)

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

void setup() {
  Wire.begin();
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("OLED not found");
    while (1);
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Hello, ESP32!");
  display.println("OLED works");
  display.display();
}

void loop() {
}

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

void setup() {
  Wire.begin();
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("OLED not found");
    while (1);
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Hello, Arduino!");
  display.println("OLED works");
  display.display();
}

void loop() {
}

Same code as ESP32. The Adafruit library uses Wire.begin() with the board's default I2C pins.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import ssd1306
import time

# ESP32: GPIO 21 (SDA), 22 (SCL)
# Pico: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000)
devices = i2c.scan()
print(f'I2C devices: {[hex(d) for d in devices]}')

oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
oled.text('Hello, MicroPython!', 0, 0)
oled.text('OLED works', 0, 16)
oled.show()

while True:
    time.sleep(1)

For the Pico, copy ssd1306.py from https://github.com/micropython/micropython-lib to the Pico's filesystem.

Raspberry Pi Python

from luma.oled.device import ssd1306
from luma.core.interface.serial import i2c
from PIL import Image, ImageDraw, ImageFont
import time

serial = i2c(port=1, address=0x3C)
device = ssd1306(serial)

with device as d:
    image = Image.new('1', d.size)
    draw = ImageDraw.Draw(image)
    draw.text((0, 0), 'Hello, Pi!', fill=255)
    draw.text((0, 16), 'OLED works', fill=255)
    d.display(image)

time.sleep(1)

Install with pip3 install luma.oled pillow. The luma library uses PIL for drawing, which is overkill for a static display but useful for graphics.

What you should see

Upload the ESP32 or Arduino version. The OLED lights up and shows "Hello, ESP32!" on the top line and "OLED works" below it.

If the OLED does not light up at all, the I2C address might be 0x3D instead of 0x3C. Run a scanner and update.

Drawing graphics

The GFX library supports shapes, lines, and rectangles:

display.clearDisplay();
display.drawRect(0, 0, 128, 64, SSD1306_WHITE);   // border
display.fillRect(10, 10, 30, 20, SSD1306_WHITE);  // filled rect
display.drawCircle(64, 32, 20, SSD1306_WHITE);    // circle
display.drawLine(0, 0, 128, 64, SSD1306_WHITE);   // diagonal line
display.setCursor(10, 50);
display.println("Graphics!");
display.display();

For a sensor dashboard with a graph, push old readings into an array and draw them as a polyline.

Text sizes

The GFX library supports 6 text sizes:

display.setTextSize(1);   // 6x8 pixels per character
display.setTextSize(2);   // 12x16
display.setTextSize(3);   // 18x24

Size 1 fits 21 characters per line on a 128-wide display. Size 2 fits 10 characters. Use size 1 for sensor readings, size 2 for the main label.

The sensor dashboard pattern

The most useful pattern is showing a sensor value with a small graph:

#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define DHT_PIN 4
DHT dht(DHT_PIN, DHT22);

Adafruit_SSD1306 display(128, 64, &Wire, -1);

float history[64];   // 64 pixels of history
int historyIdx = 0;

void setup() {
  Wire.begin();
  dht.begin();
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.clearDisplay();
}

void loop() {
  float t = dht.readTemperature();
  history[historyIdx] = t;
  historyIdx = (historyIdx + 1) % 64;

  display.clearDisplay();
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("Temp: ");
  display.print(t, 1);
  display.println(" C");

  // Draw graph
  for (int x = 0; x < 64; x++) {
    int idx = (historyIdx + x) % 64;
    int y = 63 - (int)((history[idx] - 15) * 4);   // 15-30 C maps to bottom-top
    if (y < 16) y = 16;
    if (y > 63) y = 63;
    display.drawPixel(x + 32, y, SSD1306_WHITE);
  }

  display.display();
  delay(500);
}

This shows the current temperature on the top half and a 64-sample history graph on the bottom. Common pattern for sensor nodes.

What you learned

  • The SSD1306 OLED uses I2C (2 wires) and runs on 3.3V.
  • The Adafruit SSD1306 + GFX libraries give you text, shapes, and pixel-level drawing.
  • Same library works on Arduino and ESP32 with no changes.
  • MicroPython and Pi Python have equivalent libraries.

When something breaks

  • OLED shows nothing. Wrong I2C address (run a scanner), wrong VCC (5V when it should be 3.3V), or the address is 0x3D not 0x3C.
  • OLED shows garbage. I2C bus errors. Check pull-ups.
  • OLED is dim or flickers. Add a 100nF capacitor across VCC and GND at the OLED.
  • Adafruit library hangs in begin(). Wire.begin() was not called first.

What to build next

  • The I2C LCD tutorial covers the alternative display (text only, larger characters).
  • The BME280 tutorial combines with this for a weather display.
  • The book ESP32 Smart Home covers full-screen UI design with multiple screens and navigation.

Chapter 35

ESP32: drive a 16x2 LCD display with the I2C backpack

esp32 · 25 min

The 16x2 character LCD is the workhorse display for showing text. It is the same chip that has been on Arduino projects since the early 2000s, and it still works. Pair it with the PCF8574 I2C backpack and you need only two wires (SDA, SCL) instead of the 6-10 wires a parallel hookup needs.

This tutorial covers the wiring, the library, displaying text, custom characters, and showing live sensor values.

What you need

  • ESP32 dev board (or Arduino Uno/Nano, or Pi, or Pico)
  • 16x2 LCD with the I2C backpack (the kind with 4 pins: VCC, GND, SDA, SCL; about $3)
  • 4 jumper wires

Wiring

The LCD backpack uses I2C. Same pins as the BME280.

LCD VCC -- ESP32 5V (the LCD is 5V-tolerant)
LCD GND -- ESP32 GND
LCD SDA -- ESP32 GPIO 21
LCD SCL -- ESP32 GPIO 22

Some backpacks include a small trimpot for adjusting the contrast. Turn it with a small screwdriver until you can see the text. Most ship with the contrast set too low and you see nothing until you adjust.

The LCD module is 5V. The I2C backpack usually has a pull-up resistor to 5V on SDA and SCL. On the ESP32 (3.3V GPIO), this can damage the pins over time. Add a level shifter, or use a backpack with pull-ups to 3.3V (the Adafruit one has 3.3V pull-ups).

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for LiquidCrystal_I2C. Install it (there are several variants; the one by Frank de Brabander works on both ESP32 and Arduino).

The code

ESP32 (Arduino)

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);   // address 0x27, 16 cols, 2 rows

void setup() {
  Wire.begin();
  lcd.init();
  lcd.backlight();
  lcd.print("Hello, ESP32!");
  lcd.setCursor(0, 1);
  lcd.print("I2C LCD works");
}

void loop() {
  // Nothing to do
}

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup() {
  Wire.begin();
  lcd.init();
  lcd.backlight();
  lcd.print("Hello, Arduino!");
  lcd.setCursor(0, 1);
  lcd.print("I2C LCD works");
}

void loop() {
  // Nothing to do
}

Same code as ESP32; Wire.begin() picks the right I2C pins per board (A4/A5 on the Uno, GPIO 21/22 on the ESP32).

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
from esp8266_i2c_lcd1602 import I2cLcd1602
import time

# ESP32: GPIO 21 (SDA), 22 (SCL)
# Pico: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)
devices = i2c.scan()
print(f'I2C devices: {[hex(d) for d in devices]}')

lcd = I2cLcd1602(i2c, 0x27, 2, 16)
lcd.backlight_on()
lcd.puts('Hello, MicroPython!')
lcd.move_to(0, 1)
lcd.puts('I2C LCD works')

while True:
    time.sleep(1)

The esp8266_i2c_lcd1602 library works on both ESP32 and Pico. On the Pico, copy the file from https://github.com/T-622/RPI-PICO-I2C-LCD and rename if needed.

Raspberry Pi Python

from RPLCD.i2c import CharLCD
import time

lcd = CharLCD('PCF8574', 0x27, cols=16, rows=2)
lcd.backlight_enabled = True

lcd.write_string('Hello, Pi!')
lcd.crlf()
lcd.write_string('I2C LCD works')

while True:
    time.sleep(1)

Install with pip3 install RPLCD. Enable I2C on the Pi first: sudo raspi-config >> Interface Options >> I2C >> Enable.

What you should see

Upload the ESP32 or Arduino version. The LCD lights up blue (backlight) and shows "Hello, ESP32!" on line 1 and "I2C LCD works" on line 2.

If you see nothing but a blue backlight, the contrast pot needs adjustment. Turn it slowly with a screwdriver until text appears.

If the backlight does not turn on, the I2C address might be 0x3F instead of 0x27. Run an I2C scanner sketch and update.

The I2C address

The PCF8574 backpack uses I2C address 0x27 by default. Some boards use 0x3F. If the LCD does not respond to lcd.init(), run an I2C scanner:

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin();
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print("Found: 0x");
      Serial.println(addr, HEX);
    }
  }
}

void loop() {}

Use whichever address the scanner finds.

Showing sensor values

The most useful pattern is showing a sensor reading:

#include <DHT.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>

#define DHT_PIN 4
DHT dht(DHT_PIN, DHT22);
LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup() {
  Wire.begin();
  dht.begin();
  lcd.init();
  lcd.backlight();
}

void loop() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();

  lcd.setCursor(0, 0);
  lcd.print("T:");
  lcd.print(t, 1);
  lcd.print(" C    ");

  lcd.setCursor(0, 1);
  lcd.print("H:");
  lcd.print(h, 1);
  lcd.print(" %    ");

  delay(2000);
}

The " " at the end of each line pads with spaces to clear any leftover characters from the previous update.

Custom characters

You can define up to 8 custom 5x8 pixel characters. Useful for temperature units, degree symbols, or icons:

byte degree[8] = {
  0b00110,
  0b01001,
  0b01001,
  0b00110,
  0b00000,
  0b00000,
  0b00000,
  0b00000
};

void setup() {
  lcd.init();
  lcd.createChar(0, degree);
  lcd.write(byte(0));   // print the degree symbol
}

For most projects, the standard ASCII characters are enough. Custom characters are for the cases where you want a polished UI.

What you learned

  • The 16x2 LCD with I2C backpack uses 2 GPIO pins (SDA, SCL).
  • The LiquidCrystal_I2C library handles all the I2C details.
  • Custom 5x8 characters can be defined with createChar.
  • The most useful pattern is showing live sensor readings.

When something breaks

  • LCD shows blocks but no text. Contrast pot needs adjustment.
  • LCD shows nothing at all. Wrong I2C address (run a scanner), backlight off, or wiring is wrong.
  • LCD shows garbage. I2C bus errors. Add pull-up resistors to SDA and SCL (some backpacks are missing them).
  • Text flickers. I2C bus is noisy. Add 100nF capacitors across VCC and GND at the LCD.

What to build next

  • The OLED tutorial uses a smaller, brighter display with full graphics (vs the LCD's fixed characters).
  • The BME280 tutorial combines with this for a temperature/humidity display.
  • The book ESP32 Smart Home covers LCD menu systems for selecting sensor channels.

Chapter 36

ESP32: ESP-MESH, a self-healing Wi-Fi network

esp32 · 45 min

ESP-MESH is the protocol that lets multiple ESP32 boards form a self-healing Wi-Fi network. Each board is a node; the network automatically routes messages between them. If a node goes offline, the network reroutes around it.

This is the protocol for sensor networks across a building, garden, or farm. You scatter ESP32 nodes; they find each other; they form a mesh. No router needed for the data path (though a router can be involved for the root node's external connectivity).

ESP-MESH is Espressif-specific. Arduino Uno, Raspberry Pi, and Pico do not support it.

What you need

  • 3 or more ESP32 boards (more is better; the mesh needs nodes to be useful)
  • A USB cable per board

The mesh architecture

ESP-MESH has two types of nodes:

  • Root node: connects to the router. One per mesh.
  • Child nodes: connect to the root or to other children. Form the mesh topology.

The root acts as the gateway to the outside world. Children forward packets through the mesh to reach the root, which forwards them to the internet (or wherever they need to go).

Install the library

The ESP-MESH library is part of the ESP32 Arduino core. No additional install needed. The headers are in painlessMesh.h if you want a higher-level API:

Sketch >> Include Library >> Manage Libraries >> search for painlessMesh by cochrane. Install it.

The code: root node

#include <painlessMesh.h>

#define MESH_PREFIX     "ctrlaltbrian"
#define MESH_PASSWORD   "meshpassword123"
#define MESH_PORT       5555

painlessMesh mesh;

void setup() {
  Serial.begin(115200);
  delay(1000);

  mesh.setDebugMsgTypes(ERROR | STARTUP | CONNECTION);
  mesh.init(MESH_PREFIX, MESH_PASSWORD, MESH_PORT);
  mesh.onReceive(&onReceive);
  mesh.onNewConnection(&onNewConnection);
  mesh.onChangedConnections(&onChangedConnections);
}

void loop() {
  mesh.update();
}

void onReceive(uint32_t from, String &msg) {
  Serial.printf("Received from %u: %s\n", from, msg.c_str());
}

void onNewConnection(uint32_t nodeId) {
  Serial.printf("New connection: %u\n", nodeId);
}

void onChangedConnections() {
  Serial.printf("Connections changed: %d\n", mesh.getNodeList().size());
}

The code: child node

#include <painlessMesh.h>

#define MESH_PREFIX     "ctrlaltbrian"
#define MESH_PASSWORD   "meshpassword123"
#define MESH_PORT       5555

painlessMesh mesh;

void setup() {
  Serial.begin(115200);
  delay(1000);

  mesh.setDebugMsgTypes(ERROR | STARTUP | CONNECTION);
  mesh.init(MESH_PREFIX, MESH_PASSWORD, MESH_PORT);
  mesh.onReceive(&onReceive);
}

unsigned long lastSend = 0;

void loop() {
  mesh.update();

  // Send a sensor reading every 5 seconds
  if (millis() - lastSend > 5000) {
    lastSend = millis();
    String msg = "{\"temp\":22.5,\"node\":\"";
    msg += ESP.getEfuseMac();
    msg += "\"}";
    mesh.sendBroadcast(msg);
  }
}

void onReceive(uint32_t from, String &msg) {
  Serial.printf("Received from %u: %s\n", from, msg.c_str());
}

Upload the root node to one ESP32, the child node to others. Open Serial Monitor on the root. After a few seconds, you should see "New connection" messages as the children find the root.

The "any node can be root" pattern

In a real deployment, you cannot predict which node will have access to power and the internet. The standard pattern is to designate any node as root and have the others fall back automatically.

bool isRoot = (digitalRead(4) == LOW);   // hold a button at boot to be root

void setup() {
  // ...
  if (isRoot) {
    mesh.init(MESH_PREFIX, MESH_PASSWORD, MESH_PORT, WIFI_AP_STA);
    // ... connect to router, etc.
  } else {
    mesh.init(MESH_PREFIX, MESH_PASSWORD, MESH_PORT, WIFI_STA);
  }
}

Hold the button at boot to make a node the root. Release the button to make it a child.

The message types

Three message patterns:

  • Broadcast: mesh.sendBroadcast(msg): every node receives.
  • Single node: mesh.sendSingle(nodeId, msg): only one node.
  • Specific nodeId: the destination node's ID.

Node IDs are assigned automatically when nodes join the mesh. Get the list:

auto nodes = mesh.getNodeList();
for (auto nodeId : nodes) {
  Serial.printf("Node: %u\n", nodeId);
}

Throughput

ESP-MESH is not a high-throughput protocol. It is designed for low-rate sensor data (a few KB per minute). For high-throughput applications (video, audio), use regular Wi-Fi.

Typical throughput: a few hundred bytes per second per node. Enough for sensor readings, MQTT messages, control commands.

Range and node count

Each ESP32 board has the same Wi-Fi range (200m+ line of sight). In a mesh, the effective range is the sum of the hops. A 5-node mesh across a 1 km area is realistic with line-of-sight placement.

Maximum nodes: depends on the network topology and traffic. The painlessMesh library handles 30+ nodes; the official ESP-MESH handles 1000+ (in theory).

Power consumption

Mesh nodes need to keep Wi-Fi active to participate in the mesh. Power consumption is high (50-100 mA active). For battery-powered mesh nodes, use deep sleep between messages:

void loop() {
  mesh.update();

  if (millis() - lastSend > 60000) {
    lastSend = millis();
    String msg = "{\"temp\":22.5}";
    mesh.sendBroadcast(msg);
    // Sleep for 1 minute
    esp_sleep_enable_timer_wakeup(60 * 1000000);
    esp_deep_sleep_start();
  }
}

The mesh can tolerate sleeping nodes; messages are buffered for a few seconds before being dropped.

Common projects

  • Whole-home sensor mesh. One node per room. All publish to a central MQTT broker through the root.
  • Garden monitoring. Nodes scattered across a garden, all reporting back to a root near the house.
  • Industrial monitoring. Nodes on machines across a factory floor, forming a mesh that survives machine outages.
  • Disaster-tolerant networks. Mesh networks continue to function when individual nodes fail.

What you learned

  • ESP-MESH forms a self-healing multi-hop Wi-Fi network.
  • One root node connects to the router; children forward packets.
  • painlessMesh library is the easiest way to use ESP-MESH.
  • Throughput is low (low-rate sensor data is the target).

When something breaks

  • Children cannot find the root. Too far apart, or wrong MESH_PREFIX / MESH_PASSWORD.
  • Messages dropped. Mesh congested; reduce broadcast frequency.
  • Network keeps rebuilding. Some node is dropping out; check power supply.
  • Throughput much lower than expected. Mesh is many hops; reduce hop count by adding more root nodes.

What to build next

  • The ESP-NOW tutorial covers the lower-level peer-to-peer protocol that ESP-MESH uses internally.
  • The ESP32 MQTT tutorial combines with mesh for sending data to external services.
  • The book ESP32 Mesh Networks covers multi-hop routing with custom topology.

Chapter 37

ESP32: ESP-NOW, peer-to-peer messages without a router

esp32 · 30 min

ESP-NOW is the Espressif protocol that lets ESP32 boards talk to each other directly, without a Wi-Fi router, without an access point, and without any infrastructure. Each board can send messages to up to 20 peers, and the range is 200+ meters line of sight (less through walls). Messages are limited to 250 bytes per packet.

This is the protocol I use for sensor networks, robot-to-controller links, and any project where adding a router feels like overkill.

ESP-NOW is Espressif-specific. The Arduino Uno, Pi, and Pico do not support it.

What you need

  • Two or more ESP32 boards (the protocol needs at least 2 to do anything useful)
  • A USB cable per board

The MAC address

Every ESP-NOW message is addressed by MAC address. Each ESP32 has a unique MAC burned into the chip. Get yours with this:

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  Serial.print("MAC: ");
  Serial.println(WiFi.macAddress());
}

void loop() {}

Upload this to each ESP32, record the MAC address, and use it in your sender code.

The code: sender and receiver

Sender

#include <esp_now.h>
#include <WiFi.h>

uint8_t receiverMAC[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};   // change this

typedef struct {
  int sensorValue;
  float temperature;
  char label[20];
} SensorPacket;

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);   // ESP-NOW requires station mode

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }

  esp_now_peer_info_t peerInfo = {};
  memcpy(peerInfo.peer_addr, receiverMAC, 6);
  peerInfo.channel = 0;
  peerInfo.encrypt = false;

  if (esp_now_add_peer(&peerInfo) != ESP_OK) {
    Serial.println("Failed to add peer");
    return;
  }
  Serial.println("Sender ready");
}

unsigned long lastSend = 0;

void loop() {
  if (millis() - lastSend > 1000) {
    lastSend = millis();
    SensorPacket packet;
    packet.sensorValue = analogRead(34);
    packet.temperature = 22.5;
    strcpy(packet.label, "kitchen");

    esp_err_t result = esp_now_send(receiverMAC, (uint8_t *)&packet, sizeof(packet));
    if (result == ESP_OK) {
      Serial.println("Sent");
    } else {
      Serial.print("Send failed: ");
      Serial.println(result);
    }
  }
}

Receiver

#include <esp_now.h>
#include <WiFi.h>

typedef struct {
  int sensorValue;
  float temperature;
  char label[20];
} SensorPacket;

void onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
  if (len == sizeof(SensorPacket)) {
    SensorPacket packet;
    memcpy(&packet, data, sizeof(packet));
    Serial.print(packet.label);
    Serial.print(": T=");
    Serial.print(packet.temperature);
    Serial.print(" sensor=");
    Serial.println(packet.sensorValue);
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_recv_cb(onReceive);
  Serial.println("Receiver ready");
}

void loop() {
}

Upload the receiver to one ESP32 and the sender to another. Open Serial Monitor on the receiver. After a few seconds, you should see the packets arriving.

The packet size limit

ESP-NOW limits packets to 250 bytes. For larger data, split into multiple packets or use a smaller payload. The struct above is 28 bytes (4 + 4 + 20), well within the limit.

For projects that need more than 250 bytes per message (e.g. a camera image), use Wi-Fi TCP or HTTP instead. ESP-NOW is for short messages.

The number of peers

Each ESP32 can have up to 20 encrypted peers in its peer list. The unencrypted limit is higher but rarely useful. For networks with more than 20 devices, use broadcast mode (no peer registration):

uint8_t broadcastMAC[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

void broadcast() {
  SensorPacket packet;
  // ... fill packet ...
  esp_now_send(broadcastMAC, (uint8_t *)&packet, sizeof(packet));
}

Broadcast mode is unencrypted and any ESP32 within range receives the message.

Encryption

For encrypted ESP-NOW, set the LMK (Long-term Key) on both sender and receiver:

// On both sender and receiver
esp_now_set_pmk((uint8_t *)"1234567890123456");   // 16-byte key

Both sides must use the same key. Encrypted ESP-NOW is slower (about half the throughput) but private.

The range

ESP-NOW uses the same radio as Wi-Fi, so range is similar. Line of sight, expect 200+ meters. Through walls, 30-50 meters depending on construction. For longer range, use an external antenna ESP32 module.

Combining with Wi-Fi

ESP-NOW can coexist with Wi-Fi on the same chip. The pattern:

WiFi.mode(WIFI_AP_STA);   // both AP and station
WiFi.begin(ssid, password);
esp_now_init();
// ... use both

The chip can be connected to a Wi-Fi network and sending ESP-NOW messages simultaneously. Useful for sensor nodes that publish to Wi-Fi-based MQTT and also communicate directly with each other.

Common projects

  • Sensor networks. Many ESP32 sensors broadcast their readings; a central receiver collects them.
  • Robot-to-controller. A handheld controller sends movement commands to a robot. No need for a router.
  • Trigger signals. A motion sensor sends "motion detected" to a central ESP32 that controls lights and alarms.
  • Mesh networks. Combine with ESP-MESH for self-healing multi-hop networks (see the ESP-MESH tutorial).

What you learned

  • ESP-NOW is a peer-to-peer protocol built into every ESP32.
  • Up to 20 encrypted peers, 250 bytes per packet.
  • No router or access point required.
  • Same range as Wi-Fi (200m+ line of sight).

When something breaks

  • Sender says "Send failed". Peer not added, or receiver MAC is wrong. Re-add the peer.
  • Receiver gets nothing. WiFi.mode(WIFI_STA) not set on both sides, or the receiver callback is not registered.
  • Range is much shorter than expected. Antenna is covered, or the boards are on different channels (set channel explicitly).
  • Encryption fails. LMK key is wrong on one side.

What to build next

  • The ESP-MESH tutorial combines multiple ESP-NOW links into a self-healing network.
  • The ESP32 MQTT tutorial publishes ESP-NOW-received data to a broker.
  • The book ESP32 Mesh Networks covers multi-hop ESP-NOW with routing.

Chapter 38

ESP32: use mDNS so the chip responds to a friendly name

esp32 · 25 min

mDNS (multicast DNS) is the protocol that lets you reach a device on your local network by name instead of IP address. With mDNS, you type kitchen-sensor.local instead of 192.168.1.42. No DNS server, no configuration, no router setup.

It works on the ESP32 (with Wi-Fi) and on the Raspberry Pi (with Avahi). Most modern operating systems (Windows 10+, macOS, iOS, Android) automatically resolve .local names.

What you need

  • ESP32 dev board with Wi-Fi
  • (For Pi Python section) Raspberry Pi with network

The code: ESP32

#include <WiFi.h>
#include <ESPmDNS.h>

const char* ssid = "your-wifi";
const char* password = "your-password";
const char* hostname = "kitchen-sensor";

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Set hostname
  WiFi.setHostname(hostname);

  // Initialize mDNS
  if (!MDNS.begin(hostname)) {
    Serial.println("mDNS init failed");
    return;
  }
  Serial.print("mDNS responder started: ");
  Serial.print(hostname);
  Serial.println(".local");

  // Add a service (HTTP server on port 80)
  MDNS.addService("http", "tcp", 80);
}

void loop() {
  MDNS.update();
}

Upload. Open Serial Monitor. After Wi-Fi connects, you should see:

mDNS responder started: kitchen-sensor.local

Now from any computer on the same network, you can open http://kitchen-sensor.local/ in a browser and reach the ESP32's web server.

The code: Raspberry Pi

mDNS on Linux is provided by Avahi. Most Pi OS images have it preinstalled.

sudo apt install avahi-daemon
sudo systemctl enable avahi-daemon

The Pi responds to <hostname>.local automatically once Avahi is running. Set the hostname:

sudo hostnamectl set-hostname kitchen-sensor
sudo reboot

After reboot, kitchen-sensor.local resolves to the Pi's IP.

Why mDNS is useful

Three scenarios where mDNS shines:

  1. DHCP reassigns the IP. Your router can change the ESP32's IP at any time. With mDNS, the name stays the same.
  2. Multiple devices on the same network. IP addresses are hard to remember. Names are not.
  3. Zero configuration. No DNS server, no router setup, no static IP assignment.

The downside: mDNS only works on the local network. It does not resolve over the internet. For that, you need a real DNS service.

Custom service names

Beyond HTTP, mDNS can advertise any service:

// Add a custom service
MDNS.addService("_ctrlaltbrian", "_tcp", 8080);

This advertises a _ctrlaltbrian._tcp service on port 8080. Other devices on the network can browse for this service and find your ESP32.

Useful for service discovery in IoT networks:

// On the broker/broadcaster
MDNS.addService("_mqtt", "_tcp", 1883);
MDNS.addService("_ctrlaltbrian-sensor", "_tcp", 8080);

A client looking for sensors can scan the network for the service name:

import zeroconf
zc = zeroconf.Zeroconf()
browser = zeroconf.ServiceBrowser(zc, "_ctrlaltbrian-sensor._tcp.local.", ...)

Browser compatibility

Platform mDNS support
Windows 10+ Built in. Works automatically.
Windows 7/8 Need Bonjour installed (it comes with iTunes).
macOS Built in. Works automatically.
iOS Built in. Works automatically.
Android Built in since Android 8.0.
Linux Avahi (install if not present).
Chromebook Built in.

For older Windows, install Apple's Bonjour Print Services (free). It ships with iTunes and Adobe software, so most users already have it.

The 250ms timeout

mDNS responses are sent over multicast UDP. Networks with many devices or poor Wi-Fi can drop these packets. If a name does not resolve, try again or check the network.

Tools for testing:

  • dns-sd -B _http._tcp local. (macOS): list HTTP services
  • avahi-browse -at (Linux): list all services
  • nslookup kitchen-sensor.local (Windows 10+): query a specific name

Security

mDNS has no authentication. Any device on the local network can advertise any name. A malicious device could pretend to be kitchen-sensor.local and MITM your connections. For trusted local networks this is fine; for untrusted networks, use HTTPS with proper certificate validation.

What you learned

  • mDNS lets you reach devices by name (kitchen-sensor.local).
  • It works on the ESP32 (with Wi-Fi) and the Raspberry Pi (with Avahi).
  • It is built into most modern operating systems.
  • No DNS server or configuration required.

When something breaks

  • Name does not resolve. Wi-Fi not connected, mDNS not initialized, or the OS does not support .local resolution.
  • Resolves but wrong device. Another device on the network is using the same name. Change the hostname.
  • Slow to resolve. Multicast packets dropped. Try again or use a direct IP.

What to build next

  • The ESP32 web server tutorial combines with this for named web servers.
  • The ESP-NOW tutorial uses direct MAC addressing, which complements mDNS.
  • The book ESP32 Smart Home covers service discovery across multiple devices.

Chapter 39

ESP32: set up a captive portal for Wi-Fi configuration

esp32 · 30 min

The captive portal is the pattern where an ESP32 broadcasts its own Wi-Fi network (like a router does), and when you connect to it, your phone or laptop automatically opens a configuration page. You enter your home Wi-Fi credentials, the ESP32 saves them, and restarts connected to your network.

This is how every Wi-Fi-enabled smart device handles its first-time setup. Bulbs, plugs, sensors: they all start in AP mode, you connect, you configure.

This is ESP32-specific (Wi-Fi captive portals are not a feature of the Uno, Pi, or Pico).

What you need

  • ESP32 dev board
  • A phone or laptop with Wi-Fi

Install libraries

Sketch >> Include Library >> Manage Libraries >> search for WiFiManager by tzapu. Install it.

WiFiManager is the standard library for ESP32 captive portals. It does all the AP setup, DNS handling, and config saving for you.

The code

#include <WiFiManager.h>

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Starting WiFiManager");

  WiFiManager wm;

  // Reset settings (uncomment for first-time setup)
  // wm.resetSettings();

  bool connected = wm.autoConnect("ESP32-Setup");

  if (!connected) {
    Serial.println("Failed to connect, restarting");
    ESP.restart();
  }

  Serial.println("Connected to Wi-Fi");
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  // Your main code here
}

Upload and run. The ESP32 will:

  1. If already configured: connect to the saved Wi-Fi network.
  2. If not configured: create a Wi-Fi network called "ESP32-Setup".
  3. You connect to that network with your phone.
  4. Your phone's captive portal detection opens a configuration page (some phones open it automatically; others need you to tap a "Sign In" or "Configure Wi-Fi" notification).
  5. You select your home network from the list and enter the password.
  6. The ESP32 saves the credentials and restarts.
  7. On restart, the ESP32 connects to your home network.

What the configuration page looks like

The WiFiManager library serves a configuration page with:

  • A list of visible Wi-Fi networks
  • A password field
  • A "Save" button
  • Optional: custom parameters (MQTT broker, sensor names, etc.)

You can customize the page with HTML, add a logo, or add custom parameters. The library docs at https://github.com/tzapu/WiFiManager cover this.

Adding custom parameters

For projects that need more than Wi-Fi credentials (e.g. an MQTT broker URL), add custom parameters:

WiFiManager wm;
WiFiManagerParameter mqttServer("mqtt", "MQTT Server", "192.168.1.50", 40);
WiFiManagerParameter mqttPort("port", "MQTT Port", "1883", 6);

wm.addParameter(&mqttServer);
wm.addParameter(&mqttPort);

bool connected = wm.autoConnect("ESP32-Sensor");

if (connected) {
  String server = mqttServer.getValue();
  String port = mqttPort.getValue();
  // Save to EEPROM or use directly
  preferences.begin("config", false);
  preferences.putString("mqttServer", server);
  preferences.putInt("mqttPort", port.toInt());
  preferences.end();
}

The custom parameters are saved alongside the Wi-Fi credentials and restored on reboot.

The timeout

autoConnect() blocks until either the user configures Wi-Fi or a timeout expires. Default is 3 minutes. Set your own:

wm.setConfigPortalTimeout(180);   // 3 minutes in seconds

bool connected = wm.autoConnect("ESP32-Setup");
if (!connected) {
  Serial.println("No config, going to sleep");
  esp_deep_sleep_start();
}

After timeout, the ESP32 restarts (or you can do something else, like go into a configuration mode).

The reset button pattern

If you want to clear saved credentials (so the user can reconfigure), hold a button at boot:

#include <WiFiManager.h>

const int RESET_BTN_PIN = 4;

void setup() {
  pinMode(RESET_BTN_PIN, INPUT_PULLUP);
  if (digitalRead(RESET_BTN_PIN) == LOW) {
    WiFiManager wm;
    wm.resetSettings();
    Serial.println("Settings reset, restart to enter config mode");
    while (1);   // wait for user to release button
  }
  // ... normal setup ...
}

Hold the button, power on, wait for the reset message, release, restart. The ESP32 will start in AP mode for reconfiguration.

What you learned

  • WiFiManager is the standard library for ESP32 captive portals.
  • The ESP32 starts in AP mode if not configured, or connects to the saved network if configured.
  • Custom parameters can be added for MQTT brokers, sensor names, etc.
  • A reset button is the standard way to clear saved credentials.

When something breaks

  • Phone does not auto-open the config page. Some phones do not trigger captive portal detection. Open 192.168.4.1 in a browser manually.
  • Saved credentials do not stick. Some WiFiManager versions have bugs. Update the library.
  • ESP32 keeps restarting. The configuration is invalid. Try wm.resetSettings() and reconfigure.
  • "Failed to connect" after timeout. Increase the timeout or check the Wi-Fi credentials.

What to build next

  • The Wi-Fi connect tutorial covers the basic Wi-Fi setup pattern.
  • The ESP32 MQTT tutorial combines with the captive portal for cloud-connected devices.
  • The book ESP32 Smart Home covers production-ready configuration flows with OTA updates.

Chapter 40

ESP32: mains power with the HLK-PM01 isolated supply

esp32 · 30 min

The HLK-PM01 is a small ($3-5) isolated mains-to-5V module that converts 120V or 240V AC to 5V DC at up to 600 mA. It is the safest way to power an ESP32 from a wall outlet for a permanent installation.

The alternative (cutting open a USB phone charger) works but is not isolated, not certified, and not safe. The HLK-PM01 is.

This is ESP32 + Arduino territory. Mains wiring is dangerous; you must know what you are doing.

What you need

  • HLK-PM01 module (3W, 5V output, 600 mA; about $3-5)
  • ESP32 dev board
  • Wire nuts or Wago connectors (for mains wiring)
  • Enclosure (a junction box or project box)
  • Mains cable (with ground wire if you use 3-prong outlets)

The HLK-PM01

The HLK-PM01 is a small switching power supply module. It accepts 100-240V AC input and outputs 5V DC. The input and output are galvanically isolated (no direct electrical connection), which makes it much safer than a non-isolated supply.

The module has 4 pins:

  • AC input (L and N, the two AC pins)
  • DC output (V+ and V-)

Wiring is straightforward:

Mains L (black/brown) -- HLK-PM01 AC L
Mains N (white/blue)  -- HLK-PM01 AC N
HLK-PM01 V+           -- ESP32 5V (or USB 5V pin)
HLK-PM01 V-           -- ESP32 GND

The HLK-PM01's V- (DC ground) is isolated from mains earth/ground. This is the safety feature: even if the mains wiring has a fault, the ESP32's ground is not at mains voltage.

The "safer than a phone charger" claim

Phone chargers are cheap, small, and have the same voltage output. Why not just use one?

  • Isolation: Most phone chargers are isolated, but some cheap ones are not. The HLK-PM01 is documented as isolated.
  • Certification: Phone chargers (genuine ones) are UL/CE listed. Knock-offs may not be. The HLK-PM01 is UL/CE listed.
  • Repairability: A failed phone charger is replaced, not repaired. The HLK-PM01 is a component you can swap.
  • Form factor: The HLK-PM01 has screw terminals for mains, which is safer than splicing a charger cable.

For permanent installations (anything you mount to a wall or leave plugged in for months), use a proper supply like the HLK-PM01. For prototypes and short-term projects, a phone charger is fine.

The power budget

The HLK-PM01 outputs 5V at up to 600 mA (3W). An ESP32 with Wi-Fi active draws up to 500 mA peaks. For a typical sensor project, the average draw is 50-100 mA, well within the budget.

For projects that need more power (e.g. an ESP32 with an attached camera or a 4G modem), use a larger supply like the HLK-PM12 (12W, 5V, 2.4A).

The ground loop problem

If you connect the ESP32's USB cable (for programming) while the HLK-PM01 is powering it, you create a ground loop. The USB ground and the HLK-PM01 ground are at slightly different potentials, which can cause:

  • Random resets when connecting USB
  • Damaged USB port on your computer
  • Random behavior in the ESP32

The fix: power the ESP32 from the HLK-PM01 only when programming is done. Or add an isolation barrier (a USB isolator, $20) between the computer and the ESP32.

For most projects, the workflow is:

  1. Program the ESP32 from USB.
  2. Disconnect USB.
  3. Connect the HLK-PM01.
  4. Seal the project in its enclosure.

The enclosure

Mains wiring needs to be inside an enclosure. Options:

  • Junction box (PVC or metal): the standard for electrical work. Available at any hardware store. Has knockouts for cable glands.
  • Project box (ABS plastic): easier to drill and modify. Less solid than a junction box.
  • 3D-printed enclosure: custom fit, but ABS or PETG only (PLA melts if the supply gets hot).

The enclosure must:

  • Be rated for the environment (indoor, outdoor, etc.)
  • Have proper cable glands (not just holes)
  • Allow ventilation (heat dissipation)
  • Keep mains and low-voltage wiring physically separated (double insulation or a divider)

The safety rules

  1. Unplug before wiring. Always.
  2. Use the correct wire gauge. 14 AWG for 15A circuits, 12 AWG for 20A.
  3. Use wire nuts or Wago connectors. Not solder, not electrical tape.
  4. Ground the chassis. If using a metal enclosure, ground it to mains earth.
  5. Test with a GFCI outlet first. Plug your project into a GFCI outlet; if there is a fault, the GFCI will trip and you will know.
  6. Get an electrician to check your work. If you have not worked with mains before, pay an electrician to verify your wiring.

The HLK-PM01 has a 5V output that is isolated from mains. The ESP32 running at 5V is safe to touch (no mains voltage present). The unsafe part is the AC input side. Keep that sealed.

What you learned

  • The HLK-PM01 is an isolated mains-to-5V module.
  • The ESP32 is safe to touch when powered from the HLK-PM01.
  • The enclosure must keep mains and low-voltage wiring separated.
  • A GFCI outlet is your friend.

When something breaks

  • ESP32 does not power up. HLK-PM01 wiring is wrong, or output is shorted.
  • HLK-PM01 gets hot. Normal up to about 50°C. Hotter means overloaded; check load current.
  • Random resets when USB connected. Ground loop. Disconnect USB.
  • GFCI trips. Short circuit in the wiring. Find and fix before powering on again.

What to build next

  • The 18650 + TP4056 tutorial covers the battery alternative for portable projects.
  • The solar + battery tutorial covers the perpetual-power alternative for outdoor projects.
  • The book ESP32 Smart Home covers mains-powered installations with proper safety and code compliance.

Chapter 41

ESP32: run from a USB battery bank (and why most of them break)

esp32 · 20 min

USB battery banks are the cheap, easy power source for ESP32 projects. Plug in a battery bank, plug in the ESP32, and you have hours of runtime. Most projects, no problem.

But if your ESP32 sleeps for a while, the battery bank turns off after 30-60 seconds. Then it is useless.

This is the single most common "why does my project work for an hour and then stop" complaint in ESP32 projects that run from USB battery banks. This tutorial is the honest version: what the auto-shutoff is, which battery banks have it, and the 4 ways to work around it.

The auto-shutoff feature

USB battery banks are designed for charging phones. Phone charging draws 500 mA to 2A continuously. The battery bank monitors the load current; if it drops below about 80-100 mA for 30-60 seconds, the battery bank assumes nothing is using the output and turns off.

This is great for phones (which draw continuously). It is the enemy of ESP32 projects, especially deep-sleep ones.

The workaround: keep current above the threshold

The four reliable workarounds, in order of effort:

1. Add a constant load resistor

Wire a resistor across the 5V and GND that draws 80-100 mA:

ESP32 5V --[ 47 ohm resistor ]-- GND

At 5V, a 47 ohm resistor draws about 100 mA. That keeps the battery bank awake. Power consumption: 5V * 0.1A = 0.5W, which drains a 10,000 mAh battery in 60 hours.

Downsides: heat from the resistor, faster battery drain, and the resistor is always on.

2. Periodic wake + heavy load

Have the ESP32 wake up periodically and do something that draws > 100 mA:

void loop() {
  if (millis() - lastWake > 5000) {
    // Wake and transmit
    esp_wifi_start();
    // ... Wi-Fi activity ...
    esp_wifi_stop();
    lastWake = millis();
  }

  esp_sleep_enable_timer_wakeup(5000);
  esp_deep_sleep_start();
}

This works for projects that transmit periodically. The transmissions draw > 100 mA, keeping the battery bank awake.

Downsides: every project needs a 5-second transmission window or faster. If your project sleeps for 10 minutes, the battery bank still turns off.

3. Pulse a load every 30 seconds

A timer or a separate microcontroller can pulse a load resistor:

void loop() {
  digitalWrite(LOAD_PIN, HIGH);
  delay(100);   // 100 mA draw for 100 ms
  digitalWrite(LOAD_PIN, LOW);
  delay(29000); // wait 29 seconds
}

This pulses every 29 seconds, just before the 30-second auto-shutoff. The battery bank sees a load, stays on.

Downsides: an additional GPIO pin and a load resistor. The ESP32 has to spend a tiny amount of time awake, which costs battery life.

4. Use a battery bank without auto-shutoff

Some battery banks explicitly disable the auto-shutoff:

  • RAVPower older models (the 16,000 mAh ones without Quick Charge)
  • Anker PowerCore models sold before 2018
  • Goal Zero battery banks (the outdoor ones)
  • DIY 18650 + TP4056 + boost converter (from the previous tutorial)

Newer battery banks (2018+) almost all have the auto-shutoff. Check the manufacturer's specs.

The current consumption threshold

Different battery banks have different thresholds:

Battery bank Threshold Timeout
Anker PowerCore 10000 80 mA 30 sec
Anker PowerCore 20000 80 mA 30 sec
RAVPower 16,000 50 mA 60 sec
Goal Zero Venture 30 None N/A
Cheap unbranded 100 mA 30 sec

The actual numbers vary by batch. The 80 mA / 30 sec is the most common.

How to detect the issue

If your project works for 30-60 seconds and then dies, it is the auto-shutoff. The battery bank LED turns off; the ESP32 loses power.

Test:

  1. Plug in the ESP32.
  2. Watch the battery bank's indicator LED.
  3. If the LED turns off after 30-60 seconds with the ESP32 in deep sleep, it is the auto-shutoff.
  4. Run the ESP32 with the LED on (no deep sleep). The LED stays on because the current is high enough.

The "always-on" battery banks

Some battery banks are designed for low-power devices:

  • Cygnett ChargeUp Pro (10,000 mAh): no auto-shutoff below 80 mA.
  • mophie Powerstation (some models): long timeout, no shutoff.
  • DIY: 18650 + TP4056 + boost converter is always-on.

For an ESP32 deep sleep project, get one of these or build your own.

The wiring for workaround 1 (resistor load)

ESP32 5V (USB pin) -- 47 ohm resistor -- GND

The 47 ohm resistor draws about 100 mA at 5V. Power dissipation is 0.5W; use a 1W or 2W resistor to avoid overheating.

Alternative: use a lower-current resistor and pulse a high-current load briefly:

ESP32 5V --[ 100 ohm ]-- GND         // constant 50 mA
ESP32 GPIO 4 --[ 47 ohm ]-- GND      // pulsed load, 100 mA

The 100 ohm is constant; the 47 ohm is pulsed every 30 seconds. Total load is 50 mA constant + 100 mA pulsed, which keeps the battery bank awake.

The runtime math

A 10,000 mAh USB battery bank at 5V holds 50 Wh of energy. The ESP32 in deep sleep with Wi-Fi off draws about 10 mA (50 mW). Without the constant load resistor, runtime would be 1000 hours. With a 47 ohm constant load adding 500 mW, runtime drops to:

runtime = 50 Wh / (0.05 W + 0.5 W) = 91 hours

A 47 ohm load consumes 80% of the battery. Use a higher resistance or pulsed load to extend runtime.

The best solution

For battery-bank-powered projects:

  1. Pick a battery bank without auto-shutoff, or
  2. Pulse a load every 30 seconds, or
  3. Use a 18650 + TP4056 + boost converter for the long-term solution.

The "DIY battery" approach (from the 18650 tutorial) gives you full control over power management and is the right answer for permanent projects.

What you learned

  • USB battery banks have a 30-60 second auto-shutoff for low-current loads.
  • The threshold is typically 80-100 mA.
  • Workarounds: load resistor, periodic wake, pulse load, or a battery bank without auto-shutoff.

When something breaks

  • Project runs for 30-60 seconds and dies. Auto-shutoff. Add a load resistor or pulse a load.
  • Battery bank gets hot. Load resistor too small. Increase resistance or use PWM to reduce duty cycle.
  • Project dies after 24 hours. Battery bank discharged. Replace or recharge.

What to build next

  • The 18650 + TP4056 tutorial covers the DIY battery approach that bypasses this issue entirely.
  • The deep sleep tutorial shows how to keep current low enough for any power source.
  • The book ESP32 Battery Projects covers solar + battery bank + ESP32 in depth.

Chapter 42

ESP32: supercapacitor backup for graceful power-off

esp32 · 25 min

A supercapacitor gives your ESP32 project the ability to detect a power loss and shut down gracefully. The cap holds enough charge for the ESP32 to detect the loss, save its state, and enter deep sleep or shut down cleanly.

This is the minimum hardware for projects that need to survive power cuts without corrupting state, missing data, or losing configuration.

What you need

  • ESP32 dev board
  • 1F to 10F supercapacitor (5.5V rated; about $2-5)
  • Schottky diode (1N5817 or similar)
  • 10k ohm resistor (for the voltage divider)
  • Optional: 100uF electrolytic capacitor

How it works

Power supply -- Schottky diode -- ESP32 3.3V
                      |
                      +-- Supercapacitor
                      |
                      GND

When power is applied, the supercapacitor charges through the diode. When power is removed, the diode prevents the capacitor from discharging back into the power supply. The capacitor discharges into the ESP32, which detects the voltage drop and has a few hundred milliseconds to shut down gracefully.

The math

A 1F supercapacitor at 5V holds:

E = 0.5 * C * V^2 = 0.5 * 1F * 5^2 = 12.5 J

The ESP32 in active mode consumes about 0.1W (30 mA at 3.3V). The cap can power the ESP32 for:

t = E / P = 12.5 J / 0.1 W = 125 seconds

But you only need a few hundred milliseconds for graceful shutdown. A 1F cap gives you 100x more than needed. A 100mF cap (about $1) is enough.

For longer hold-up times (e.g. 30 seconds for a proper shutdown sequence), use a 1F to 10F cap.

Wiring

5V power supply + -- diode anode
diode cathode    -- ESP32 5V pin (or 3.3V via regulator)
ESP32 5V pin     -- Supercapacitor +
Supercapacitor - -- ESP32 GND
ESP32 3.3V       -- 10k resistor -- Supercapacitor + (voltage divider tap)
ESP32 GND        -- Supercapacitor -

The voltage divider (10k to 3.3V, 10k to ground) lets you monitor the capacitor voltage through the ADC.

The supercapacitor voltage can exceed 5V if the supply is 5V. Use a 5.5V or higher rated supercapacitor. Common ratings: 5.5V (most common), 2.7V (lower voltage, smaller size).

The detection code

const int V_MONITOR_PIN = 34;

float readCapVoltage() {
  int raw = analogRead(V_MONITOR_PIN);
  return raw * 3.3 / 4095.0 * 2.0;   // voltage divider halves the voltage
}

float lastVoltage;

void setup() {
  Serial.begin(115200);
  delay(1000);
  lastVoltage = readCapVoltage();
  Serial.print("Cap voltage: ");
  Serial.println(lastVoltage);
}

void loop() {
  float v = readCapVoltage();

  // Power lost: voltage dropped more than 0.5V
  if (v < lastVoltage - 0.5) {
    Serial.println("POWER LOST! Saving state...");
    saveState();
    Serial.println("State saved, sleeping");
    esp_deep_sleep_start();
  }

  lastVoltage = v;
  delay(100);
}

void saveState() {
  // Save important state to EEPROM or RTC memory
  // This runs within the few hundred milliseconds the supercap gives you
}

The threshold (0.5V drop) is the key. A small drop is normal as the capacitor charges/discharges slightly. A drop of more than 0.5V in a single loop iteration means power was lost.

The deep sleep wake on power

After detecting power loss and saving state, the ESP32 should enter deep sleep. When power returns, the ESP32 wakes from deep sleep and resumes.

For projects that should be off until power returns, use a GPIO interrupt to wake:

esp_sleep_enable_ext0_wakeup(POWER_DETECT_PIN, 1);   // wake when power returns

// Before sleep:
esp_deep_sleep_start();

The "RTC memory" pattern

RTC memory is the ESP32's special memory that survives deep sleep. Use it to save the most critical state during graceful shutdown:

RTC_DATA_ATTR int lastBootCount = 0;
RTC_DATA_ATTR float lastSensorReading = 0.0;

void setup() {
  lastBootCount++;
  Serial.print("Boot count: ");
  Serial.println(lastBootCount);
}

RTC memory survives deep sleep without needing the supercapacitor. The supercapacitor only needs to last long enough for saveState() to run, which writes to RTC memory directly.

The 1N5817 diode

The diode prevents the supercapacitor from discharging back into the power supply. Without it, the cap's charge flows back through the power supply when it loses power.

The 1N5817 (1A Schottky) is the standard pick. Its low forward drop (0.3V) means less voltage loss during normal operation.

For high-current projects (above 1A), use a larger diode like the MBR20100 (20A Schottky).

What you learned

  • A supercapacitor gives the ESP32 time to detect power loss and shut down gracefully.
  • A 1F cap provides 100x more hold-up time than needed for most projects.
  • The Schottky diode prevents reverse discharge.
  • RTC memory preserves state through the shutdown.

When something breaks

  • ESP32 does not detect power loss. Voltage divider wrong, or the threshold is too high.
  • ESP32 does not get enough hold-up time. Capacitor too small, or the shutdown code is too slow.
  • Capacitor voltage drops too fast. Load is too heavy (something other than the ESP32 is drawing current).
  • Capacitor voltage does not recover. Power supply is weak (cannot source enough current to charge the cap).

What to build next

  • The 18650 + TP4056 tutorial covers longer-duration battery backup.
  • The deep sleep tutorial covers the code patterns for low-power ESP32.
  • The book ESP32 Industrial IoT covers supercapacitor sizing for various hold-up time requirements.

Chapter 43

ESP32: build a plant monitor with soil moisture and auto-watering

esp32 · 45 min

A plant monitor with automatic watering. Soil moisture sensor reads the soil, ESP32 turns on a 12V water pump when the soil is dry, pump runs for 5 seconds, ESP32 sleeps for 5 minutes before checking again. Solar-powered for outdoor planters.

This is the project that ties the soil moisture, transistor, and solar+battery tutorials together.

What you need

  • ESP32 dev board
  • Capacitive soil moisture sensor
  • 12V DC peristaltic pump (the kind for hydroponics; about $10)
  • 2N2222 NPN transistor
  • 1k ohm resistor (base resistor)
  • 1N4007 flyback diode (for the pump's inductive kick)
  • 12V power supply (or a 3S 18650 pack for solar)
  • 18650 + TP4056 + LDO (for the ESP32 side)
  • Wires, weatherproof enclosure

Wiring

ESP32 GPIO 4 --[ 1k resistor ]-- 2N2222 base
ESP32 GPIO 34 -- Soil sensor AOUT
ESP32 GND -- Soil sensor GND
ESP32 GND -- 2N2222 emitter
ESP32 3.3V -- Soil sensor VCC
2N2222 collector -- Pump negative
Pump positive -- 12V supply positive
12V supply GND -- ESP32 GND (common ground)
1N4007 diode cathode (stripe) -- Pump positive
1N4007 diode anode (no stripe) -- 2N2222 collector

The 2N2222 switches the pump's negative wire. The flyback diode absorbs the inductive kickback when the pump turns off (from the motor's windings). Without the diode, the 2N2222 will eventually die.

The code

const int PUMP_PIN = 4;
const int SOIL_PIN = 34;

const int DRY_VALUE = 2800;
const int WET_VALUE = 400;
const float THRESHOLD = 30.0;

const unsigned long PUMP_DURATION = 5000;
const unsigned long CHECK_INTERVAL = 300000;   // 5 minutes

float readMoisturePercent() {
  int raw = analogRead(SOIL_PIN);
  float pct = (float)(DRY_VALUE - raw) / (DRY_VALUE - WET_VALUE) * 100.0;
  return constrain(pct, 0.0, 100.0);
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  pinMode(PUMP_PIN, OUTPUT);
  digitalWrite(PUMP_PIN, LOW);
  Serial.println("Plant monitor active");
}

void loop() {
  float moisture = readMoisturePercent();
  Serial.print("Moisture: ");
  Serial.print(moisture, 1);
  Serial.print("% (threshold: ");
  Serial.print(THRESHOLD, 1);
  Serial.println("%)");

  if (moisture < THRESHOLD) {
    Serial.println("Soil dry, watering for 5 seconds");
    digitalWrite(PUMP_PIN, HIGH);
    delay(PUMP_DURATION);
    digitalWrite(PUMP_PIN, LOW);
    Serial.println("Watering complete");
    delay(60000);   // wait a minute for water to absorb
  }

  delay(CHECK_INTERVAL);
}

Upload. Insert the soil sensor into the plant. The pump will turn on when the soil is dry.

The threshold

The 30% threshold is a starting point. Adjust based on your plant:

  • Succulents (low water): 20% threshold.
  • Herbs (moderate water): 40% threshold.
  • Vegetables (high water): 50% threshold.
  • Tropical plants: 60% threshold.

Calibrate by watching your plant for a week and noting the moisture reading when it starts to wilt.

The pump runtime

5 seconds delivers about 50 mL of water. That is enough for a small planter. For larger planters, increase to 10-15 seconds. Too much water and the plant's roots will rot; too little and the plant will not get enough.

The "water once per cycle" pattern

A common bug: the soil is dry, the pump runs, the soil is still dry (water has not absorbed yet), the pump runs again. To prevent this, the code above waits 60 seconds after watering before re-reading.

For projects with multiple plants, water each one in sequence with a multi-channel relay:

const int PUMP1_PIN = 4;
const int PUMP2_PIN = 5;
const int PUMP3_PIN = 6;
const int NUM_PUMPS = 3;
const int PUMP_PINS[] = {4, 5, 6};

void waterAll() {
  for (int i = 0; i < NUM_PUMPS; i++) {
    float moisture = readMoisturePercent(i);
    if (moisture < THRESHOLD) {
      digitalWrite(PUMP_PINS[i], HIGH);
      delay(PUMP_DURATION);
      digitalWrite(PUMP_PINS[i], LOW);
      delay(60000);
    }
  }
}

Solar + battery

For outdoor use without a power outlet, run the ESP32 from the solar

  • battery setup from the previous tutorial. The pump draws 12V from the solar panel directly; the ESP32 draws 3.3V from the LDO.
Solar panel + -- TP4056 IN+ -- TP4056 OUT+ -- LDO -- ESP32 3.3V
Solar panel - -- TP4056 IN- -- 12V supply + -- Pump +
                  TP4056 OUT- -- ESP32 GND = Pump - (common GND)

The solar panel must be rated for both the ESP32 (5V charging the 18650) and the pump (12V). A 12V solar panel works for both with a 12V-to-5V step-down for the TP4056.

The weatherproof enclosure

The pump and electronics need weather protection:

  • Pump: fully waterproof submersible pumps can be submerged; other pumps need a dry enclosure.
  • Electronics: outdoor junction box or 3D-printed enclosure with IP65 rating.
  • Soil sensor: the exposed probe end is waterproof; the electronics end needs protection.

Mount the electronics in the enclosure, run the soil sensor wire down into the soil, run the pump's intake and output tubing into the planter.

What to learn

  • The transistor + flyback diode pattern for switching inductive loads.
  • The threshold-based auto-watering logic.
  • The 5-minute check interval (long enough to not overwater, short enough to not let the plant wilt).

What to build next

  • The soil moisture tutorial covers the sensor in detail.
  • The transistor tutorial covers the switch circuit.
  • The MQTT tutorial publishes the watering events to a dashboard.

Chapter 44

ESP32: solar + 18650 battery for perpetual projects

esp32 · 30 min

Adding a small solar panel to your 18650 battery setup turns a project that runs for weeks into one that runs forever. The math is simple: a small solar panel (1-5W) provides more energy per day than most ESP32 projects use.

This tutorial covers the wiring, the panel sizing math, and the charge controller setup that prevents the panel from overcharging the battery.

What you need

  • 18650 cell (genuine Samsung/LG/Panasonic/Sony, 2500-3500 mAh)
  • TP4056 charge controller with protection (the version with DW01)
  • Small solar panel (5-6V, 1-5W; about $5-15)
  • Schottky diode (1N5817 or similar; 1A rated)
  • LDO regulator (ME6211 or HT7333, 3.3V output, low quiescent current)
  • Capacitors: 100uF and 10uF electrolytic, 100nF ceramic

The basic wiring

Solar panel + -- TP4056 IN+ (5V)
Solar panel - -- TP4056 IN- (GND)
TP4056 OUT+   -- LDO IN (3.7-4.2V battery voltage)
TP4056 OUT-   -- LDO GND
LDO OUT        -- ESP32 3.3V
LDO GND        -- ESP32 GND

Without the diode (added in the next section), this works for sunny days but fails at night when the battery drains backwards through the panel.

The reverse current problem

Solar panels generate electricity whenever light hits them. At night, they are still generating: just not enough to charge the battery. The panel becomes a path for the battery to discharge.

A Schottky diode in series with the panel blocks this reverse current:

Solar panel + -- diode cathode (stripe side)
                -- TP4056 IN+

The diode drops about 0.3V (Schottky) or 0.7V (silicon). The TP4056's input range is 4.5-5.5V, so a Schottky diode works without losing much charge current.

The 1N5817 is the standard Schottky diode for this (1A, low forward drop). Most small solar panels (under 5W) work fine with it.

The panel sizing math

For an ESP32 sensor project that uses 50 mAh per day (about 2 mA average current):

  • Battery capacity: 2500 mAh
  • Days of battery life without solar: 50 days
  • Solar needed to replenish 50 mAh/day: depends on sun hours

In a typical location with 4 sun-hours per day, a 1W solar panel provides about 200 mAh per day (after conversion losses). That is 4x the consumption, which is the right margin.

For the same project in winter (1-2 sun-hours per day), a 2W panel is the right pick.

For projects in low-light (inside a window), a 5W panel is needed to overcome the 50-70% light loss through glass.

The TP4056 solar charge controller

The TP4056 charges the 18650 from the solar panel through the same CC/CV profile as USB charging. It works fine for solar as long as the panel output voltage is in the 4.5-5.5V range.

The TP4056's charge LED indicates charging status:

  • Off: not charging (no input, or input below 4.5V)
  • Red: charging
  • Green: charge complete

For solar projects, the LED is a useful diagnostic: if it never turns red during the day, the panel is not delivering enough voltage.

Deep sleep + solar

The combination of deep sleep + solar is the right pattern for perpetual sensor nodes:

void loop() {
  // Take a sensor reading
  float reading = readSensor();

  // Publish it
  publishMqtt(reading);

  // Sleep for 5 minutes
  esp_sleep_enable_timer_wakeup(5 * 60 * 1000000ULL);
  esp_deep_sleep_start();
}

With 5-minute deep sleep, the ESP32 wakes 288 times per day. Each wake consumes about 50 mA for 5 seconds (publishing). The average current is about 1 mA, or 24 mAh per day. A 1W solar panel in 4 sun-hours provides enough.

The low battery protection

The TP4056 with the DW01 protection chip cuts off the battery at 2.5V to prevent over-discharge. This protects the cell but means your project will stop working in low-sun conditions.

For mission-critical projects, add a battery voltage monitor and warn the user:

float batteryVoltage = analogRead(BATT_PIN) * 2.0 * 3.3 / 4095.0;
if (batteryVoltage < 3.3) {
  // Battery low, skip this wake cycle to save power
  esp_sleep_enable_timer_wakeup(60 * 60 * 1000000ULL);   // sleep for an hour
  esp_deep_sleep_start();
  return;
}

The voltage divider (R1 + R2) halves the battery voltage so the ADC can read it (see the 18650 tutorial for the wiring).

Solar + temperature

The 18650 cell's capacity drops at low temperatures. Below 0°C, you lose about 30% capacity. For outdoor projects in cold climates, mount the battery in an insulated enclosure or add a small heating resistor.

The enclosure

Outdoor solar projects need an enclosure:

  • IP65 or better (rain-proof)
  • Transparent top (for the solar panel) or external solar panel
  • Ventilation hole with Gore-Tex membrane (for pressure equalization)
  • Cable glands for any external sensors

A standard electrical junction box with a clear lid works for many projects. For permanent installations, use a metal NEMA enclosure.

Common projects

  • Garden sensors. Soil moisture, temperature, light. Perpetual operation through spring/summer/fall.
  • Remote weather stations. Solar + battery + temperature + humidity
    • pressure.
  • Trail cameras. Solar + battery + ESP32-CAM. The ESP32-CAM can run on solar for years with motion-triggered captures.
  • Asset trackers. GPS + solar + ESP32. Reports position once per hour.

What you learned

  • A small solar panel (1-5W) makes most ESP32 projects perpetual.
  • The Schottky diode prevents reverse current drain at night.
  • The TP4056 charges the battery from the solar panel.
  • Deep sleep + solar is the right pattern for sensor nodes.

When something breaks

  • Battery dies in a few days. Solar panel not charging (check voltage), or current consumption is higher than expected (check deep sleep current).
  • Battery swells or vents. Counterfeit cell, or overcharging. Get a genuine cell and verify TP4056 charge voltage (4.2V).
  • Panel voltage drops at peak sun. Panel is shaded, or the diode is in backwards.
  • TP4056 LED never lights. Panel voltage too low (under 4.5V), or panel wiring is wrong.

What to build next

  • The 18650 + TP4056 tutorial covers the basic battery setup without solar.
  • The deep sleep tutorial shows the code patterns for low-power ESP32.
  • The book ESP32 Solar Projects covers outdoor enclosures, panel sizing for various climates, and remote monitoring.

Chapter 45

ESP32: build a weather station that logs to Wi-Fi

esp32 · 60 min

This is the project tutorial that ties together everything from the foundations through the power cluster. A working weather station: ESP32 reads a BME280, serves the readings on a Wi-Fi web page, sleeps between readings to save power, and runs from a 18650 battery.

It is built from the parts in the tutorials in this book. If you have read the BME280, Wi-Fi, deep sleep, and 18650 tutorials, you have seen all the pieces. This is where they come together.

What you need

  • ESP32 dev board
  • BME280 breakout (the I2C variant)
  • 18650 + TP4056 + LDO regulator (from the 18650 tutorial)
  • 4 jumper wires
  • A USB cable for programming
  • A weatherproof enclosure (a clear plastic food container works for prototyping)

Wiring

ESP32 3.3V -- BME280 VCC
ESP32 GND  -- BME280 GND
ESP32 GPIO 21 -- BME280 SDA
ESP32 GPIO 22 -- BME280 SCL
ESP32 3.3V -- LDO OUT (from TP4056)
ESP32 GND  -- LDO GND (from TP4056)

The BME280 is on the default I2C pins. The LDO provides 3.3V from the 18650 battery. Add a 100uF capacitor across the LDO output for noise filtering.

The code

#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <esp_sleep.h>

const char* ssid = "your-wifi";
const char* password = "your-password";

#define I2C_SDA 21
#define I2C_SCL 22

WebServer server(80);
Adafruit_BME280 bme;

float lastTemp = 0;
float lastHum = 0;
float lastPress = 0;
unsigned long lastRead = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  Wire.begin(I2C_SDA, I2C_SCL);
  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    ESP.restart();
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.begin();

  // Take a reading now
  takeReading();

  // Sleep for 60 seconds
  esp_sleep_enable_timer_wakeup(60 * 1000000ULL);
  Serial.println("Going to sleep for 60 seconds");
  esp_deep_sleep_start();
}

void loop() {
  // Not reached; the ESP32 wakes from sleep and starts at setup()
  server.handleClient();
}

void takeReading() {
  lastTemp = bme.readTemperature();
  lastHum = bme.readHumidity();
  lastPress = bme.readPressure() / 100.0;
  lastRead = millis();
  Serial.printf("Temp: %.2f, Hum: %.2f, Press: %.2f\n",
                lastTemp, lastHum, lastPress);
}

void handleRoot() {
  String html = "<!DOCTYPE html><html><head>";
  html += "<meta charset='utf-8'>";
  html += "<meta http-equiv='refresh' content='5'>";
  html += "<title>Weather Station</title></head>";
  html += "<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;'>";
  html += "<h1>Weather Station</h1>";
  html += "<p><strong>Temperature:</strong> " + String(lastTemp, 1) + " &deg;C</p>";
  html += "<p><strong>Humidity:</strong> " + String(lastHum, 1) + " %</p>";
  html += "<p><strong>Pressure:</strong> " + String(lastPress, 1) + " hPa</p>";
  html += "<p><small>Last update: " + String(lastRead / 1000) + " seconds after boot</small></p>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

Upload. Open Serial Monitor. After Wi-Fi connects, note the IP address. Open it in a browser. You should see the current temperature, humidity, and pressure.

The ESP32 then sleeps for 60 seconds. After 60 seconds, it wakes, takes a new reading, serves the web page again, and sleeps again. The web page refreshes every 5 seconds, but the readings only update every 60 seconds (when the ESP32 wakes).

The 60-second deep sleep

The 60-second sleep interval is a tradeoff:

  • Longer sleep: less Wi-Fi time, lower battery drain, less current data freshness.
  • Shorter sleep: more Wi-Fi time, higher battery drain, fresher data.

For a weather station, 60 seconds is the right pick. Atmospheric pressure changes on the order of minutes, not seconds.

For projects that need faster updates (e.g. a burglar alarm), shorten the sleep to 1-5 seconds and accept the battery drain.

The battery math

Average current draw:

  • Wake period (5 seconds): Wi-Fi active, ~100 mA. = 0.14 mAh per wake.
  • Sleep period (55 seconds): Wi-Fi off, ~10 mA. = 0.15 mAh per sleep.

Total: 0.29 mAh per minute = 17 mAh per hour = 410 mAh per day.

A 2500 mAh 18650 lasts about 6 days. For longer runtime, use a larger battery (e.g. 18650 + parallel) or a solar panel.

Adding a solar panel

For perpetual operation, wire a solar panel through the TP4056:

Solar panel -- TP4056 IN+ -- TP4056 OUT+ -- LDO -- ESP32

A 1W solar panel in 4 sun-hours per day provides about 200 mAh, which is half the daily consumption. Use a 2W panel for 100% replenishment.

The weatherproof enclosure

The ESP32 and battery need to be protected from rain. Options:

  • Clear plastic food container: cheap, easy to modify, works for prototyping. Drill holes for ventilation, seal with silicone.
  • Outdoor electrical junction box: the standard for permanent installations. Available at any hardware store.
  • 3D-printed enclosure: custom fit. Use ABS or PETG (PLA melts in summer sun).

The BME280 needs to be exposed to outside air but protected from direct rain. Mount it under a small overhang or with a Gore-Tex membrane over the sensor.

Logging to a database

For historical data, post to an MQTT broker or HTTP API. Combine with the MQTT tutorial:

#include <PubSubClient.h>

WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

void setup() {
  // ... after Wi-Fi setup ...
  mqtt.setServer("192.168.1.50", 1883);
  mqtt.connect("esp32-weather");
}

void takeReading() {
  lastTemp = bme.readTemperature();
  lastHum = bme.readHumidity();
  lastPress = bme.readPressure() / 100.0;

  char payload[100];
  snprintf(payload, sizeof(payload),
    "{\"temp\":%.2f,\"hum\":%.2f,\"press\":%.2f}",
    lastTemp, lastHum, lastPress);
  mqtt.publish("weather/sensor", payload);
}

The Node-RED tutorial on the Pi shows how to consume these readings into InfluxDB or another time-series database.

What you learned

  • A working weather station can be built from the tutorials in this book.
  • The ESP32 wakes every 60 seconds, takes a reading, serves it on Wi-Fi, sleeps again.
  • Battery runtime is about 6 days with a single 18650.
  • Adding solar makes the project perpetual.

What to build next

  • The MQTT tutorial publishes these readings to a broker.
  • The Raspberry Pi Node-RED tutorial consumes the readings and graphs them.
  • The book IoT with ESP32 has more weather station patterns (wind speed, rain gauge, UV sensor).

Chapter 46

ESP32: build a home sensor hub with MQTT and Node-RED

esp32 · 60 min

This is the project that ties the ESP32 sensor tutorials to the Raspberry Pi MQTT and Node-RED tutorials. Multiple ESP32 boards publish sensor readings to an MQTT broker running on a Raspberry Pi. The Pi runs Node-RED, which subscribes to the readings and exposes them on a dashboard.

It is the system I run in my house. Several ESP32 nodes, one Pi, one dashboard. Total cost: about $50 in hardware.

The architecture

ESP32 sensor 1 (kitchen)        ESP32 sensor 2 (bedroom)
       |                                |
       | MQTT over Wi-Fi               |
       +----------+----------------------+
                  |
                  v
         Raspberry Pi
         (MQTT broker + Node-RED + dashboard)
                  |
                  v
            Any browser
        (http://raspberrypi.local:1880/ui)

The ESP32s are clients. The Pi is the broker. Node-RED is the dashboard.

What you need

  • 2 or more ESP32 boards (the sensors)
  • BME280 breakout (one per ESP32)
  • 18650 + TP4056 (one per ESP32)
  • Raspberry Pi (any model with Wi-Fi; 3B+ or 4 recommended)
  • microSD card with Raspberry Pi OS
  • Network with Wi-Fi (the ESP32s and Pi need to be on the same LAN)

Step 1: set up the Pi

Flash Raspberry Pi OS Lite onto the SD card. Boot the Pi, connect it to your Wi-Fi (via raspi-config or by editing /etc/wpa_supplicant/).

Install Mosquitto (the MQTT broker) and Node-RED:

sudo apt update
sudo apt install mosquitto mosquitto-clients -y
sudo systemctl enable mosquitto
sudo systemctl start mosquitto

bash <(curl -sL https://raw.githubusercontent.com/node-red/linux-installers/master/deb/update-nodejs-and-nodered)
sudo systemctl enable nodered
sudo systemctl start nodered

The Mosquitto broker listens on port 1883. Node-RED listens on port 1880.

Verify:

mosquitto_sub -h localhost -t test &
mosquitto_pub -h localhost -t test -m "hello"

You should see "hello" appear in the subscriber. MQTT is working.

Step 2: program the ESP32s

Each ESP32 needs the same code with different room labels. The full code:

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

const char* ssid = "your-wifi";
const char* password = "your-password";
const char* mqttServer = "192.168.1.50";   // Pi's IP
const int mqttPort = 1883;
const char* roomName = "kitchen";   // change per ESP32

WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Adafruit_BME280 bme;

unsigned long lastPublish = 0;
const unsigned long PUBLISH_INTERVAL = 30000;   // 30 seconds

void setup() {
  Serial.begin(115200);
  delay(1000);

  Wire.begin(21, 22);
  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    ESP.restart();
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  mqtt.setServer(mqttServer, mqttPort);
  Serial.print("ESP32 ");
  Serial.print(roomName);
  Serial.print(" connected. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  if (!mqtt.connected()) {
    reconnect();
  }
  mqtt.loop();

  if (millis() - lastPublish > PUBLISH_INTERVAL) {
    lastPublish = millis();
    publishReading();
  }
}

void publishReading() {
  float t = bme.readTemperature();
  float h = bme.readHumidity();
  float p = bme.readPressure() / 100.0;

  char topic[50];
  snprintf(topic, sizeof(topic), "home/%s/sensor", roomName);

  char payload[150];
  snprintf(payload, sizeof(payload),
    "{\"temp\":%.2f,\"hum\":%.2f,\"press\":%.2f}",
    t, h, p);

  mqtt.publish(topic, payload);
  Serial.print("Published to ");
  Serial.print(topic);
  Serial.print(": ");
  Serial.println(payload);
}

void reconnect() {
  while (!mqtt.connected()) {
    String clientId = "esp32-";
    clientId += roomName;
    if (mqtt.connect(clientId.c_str())) {
      Serial.println("MQTT connected");
    } else {
      Serial.print("MQTT failed, rc=");
      Serial.print(mqtt.state());
      delay(5000);
    }
  }
}

Upload to each ESP32 with a different roomName (kitchen, bedroom, office, etc.). Open Serial Monitor to see the publish confirmations.

Step 3: verify the messages

On the Pi, subscribe to all home topics:

mosquitto_sub -h localhost -t "home/#" -v

You should see JSON messages appearing every 30 seconds:

home/kitchen/sensor {"temp":22.5,"hum":45.2,"press":1013.2}
home/bedroom/sensor {"temp":21.8,"hum":50.1,"press":1013.4}
home/office/sensor {"temp":23.1,"hum":42.8,"press":1013.3}

If you do not see messages, check:

  • The ESP32 is connected to Wi-Fi.
  • The MQTT server IP is correct.
  • Mosquitto is running (sudo systemctl status mosquitto).

Step 4: build the Node-RED dashboard

Open http://raspberrypi.local:1880/ in a browser. Node-RED's UI loads. Build this flow:

  1. MQTT input node: subscribe to home/+/sensor. The + wildcard matches all rooms.
  2. JSON parser: parse the payload as JSON.
  3. Function node: extract room name from the topic.
  4. Gauge nodes: display temp, humidity, pressure.

Drag nodes from the left palette. Wire them by dragging from one node's output to the next's input. Click the MQTT node, set the topic to home/+/sensor, click Deploy. Repeat for the other nodes.

The JSON parser outputs an object with temp, hum, press. The function node can split these into separate paths:

msg.topic = msg.topic;   // e.g. "home/kitchen/sensor"
msg.room = msg.topic.split('/')[1];   // "kitchen"
return msg;

Each of the three Gauge nodes gets a different value from the JSON. Add a text node to show the room name.

For historical graphs, add a Chart node and store data in InfluxDB or SQLite. The book Home Automation with Raspberry Pi covers this.

The total cost

Component Cost
3 ESP32 boards $15
3 BME280 breakouts $6
3 18650 cells (genuine) $15
3 TP4056 boards $3
3 3.3V LDO regulators $3
Raspberry Pi 4 (2 GB) $35
microSD card $8
Jumpers, wires, enclosures $20
Total $105

For a smaller network (1 ESP32), drop to $50. For more nodes, add $15 per additional ESP32 sensor.

What you learned

  • A complete home sensor network with ESP32 + MQTT + Node-RED + Pi.
  • Each ESP32 publishes sensor readings as JSON over MQTT.
  • Node-RED subscribes and routes to gauges and charts.
  • Total cost: about $50-$100 depending on node count.

What to build next

  • The Raspberry Pi Node-RED tutorial covers the dashboard side.
  • The MQTT tutorial covers publishing from the ESP32 side.
  • The book IoT with ESP32 has more advanced patterns (alerting, historical storage, automation rules).

Chapter 47

ESP32: build an indoor air quality monitor

esp32 · 45 min

An indoor air quality monitor that displays CO2 equivalent, combustible gas level, temperature, and humidity on an OLED. BME280 for the climate data, MQ-2 for the combustible gas sensor, MQ-135 for CO2 equivalent, OLED for the display.

This project combines the BME280, MQ-2, and OLED tutorials into a finished device.

What you need

  • ESP32 dev board
  • BME280 breakout (I2C)
  • MQ-2 gas sensor module (with breakout)
  • MQ-135 air quality sensor module
  • SSD1306 OLED display (128x64, I2C)
  • 4 jumper wires for each sensor (12 total)

Wiring

ESP32 3.3V -- BME280 VCC, OLED VCC
ESP32 GND  -- all sensor GND
ESP32 GPIO 21 -- BME280 SDA, OLED SDA (shared I2C bus)
ESP32 GPIO 22 -- BME280 SCL, OLED SCL

ESP32 5V -- MQ-2 VCC (heater)
ESP32 GND -- MQ-2 GND
ESP32 GPIO 34 -- MQ-2 AO (analog)
(MQ-2 DO not connected)

ESP32 5V -- MQ-135 VCC
ESP32 GND -- MQ-135 GND
ESP32 GPIO 35 -- MQ-135 AO
(MQ-135 DO not connected)

The BME280 and OLED share the I2C bus. The MQ sensors use separate analog inputs.

The MQ sensors need 5V for the heater to reach operating temperature. The analog output is in the 0-5V range. The ESP32's ADC can only read 0-3.3V; readings above 3.3V will saturate. Add a voltage divider (1k + 2k ohm) to scale down the MQ output to 0-3.3V.

The code

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

Adafruit_BME280 bme;

const int MQ2_PIN = 34;
const int MQ135_PIN = 35;

void setup() {
  Serial.begin(115200);
  delay(1000);

  Wire.begin(21, 22);
  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    while (1);
  }

  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("Could not find OLED");
    while (1);
  }

  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  Serial.println("Air quality monitor ready");
}

unsigned long lastRead = 0;

void loop() {
  if (millis() - lastRead > 2000) {
    lastRead = millis();
    updateDisplay();
  }
}

void updateDisplay() {
  float temp = bme.readTemperature();
  float hum = bme.readHumidity();
  int mq2Raw = analogRead(MQ2_PIN);
  int mq135Raw = analogRead(MQ135_PIN);

  // Invert MQ readings (lower = more gas)
  int mq2Air = 4095 - mq2Raw;     // 0 = clean, 4095 = max gas
  int mq135Air = 4095 - mq135Raw; // same

  display.clearDisplay();

  // Line 1: temperature
  display.setCursor(0, 0);
  display.print("T:");
  display.print(temp, 1);
  display.print("C H:");
  display.print(hum, 0);
  display.println("%");

  // Line 2: CO2 equivalent (rough)
  display.setCursor(0, 16);
  display.print("CO2:");
  display.print(map(mq135Air, 0, 4095, 400, 2000));
  display.println("ppm");

  // Line 3: gas level
  display.setCursor(0, 32);
  display.print("Gas:");
  int gasPct = map(mq2Air, 0, 4095, 0, 100);
  display.print(gasPct);
  display.println("%");

  // Bar graph for gas
  display.drawRect(0, 48, 128, 12, SSD1306_WHITE);
  display.fillRect(2, 50, map(gasPct, 0, 100, 0, 124), 8, SSD1306_WHITE);

  display.display();
}

Upload. The OLED shows temperature, humidity, CO2 estimate, and gas level. The bar graph at the bottom visualizes the gas reading.

The MQ sensor warm-up

MQ sensors need 24 hours of continuous power before they give stable readings. The first day, the readings will drift as the heater warms up. After that, they are stable.

For projects where this matters (e.g. production deployment), add a "calibration mode" message that displays the warm-up status.

The CO2 estimate

The MQ-135 is sensitive to multiple gases including CO2, NH3, and alcohol. The "CO2 equivalent" value is an estimate that assumes the sensor is in a typical indoor environment.

For accurate CO2 readings, use a dedicated CO2 sensor like the MH-Z19 or SCD30. Those cost more ($20-30) but give true CO2 values.

The "alarm when gas is detected" addition

Add a buzzer that activates when gas exceeds a threshold:

const int BUZZER_PIN = 4;

void loop() {
  // ... after updateDisplay() ...

  if (mq2Air > 3000) {   // very high gas
    digitalWrite(BUZZER_PIN, HIGH);
  } else {
    digitalWrite(BUZZER_PIN, LOW);
  }
}

The buzzer tone (from the buzzer tutorial) makes an alarm sound. For real safety, do not rely on this for gas leak detection; use a commercial detector.

The data logging addition

For a permanent record of air quality, log to MQTT:

#include <PubSubClient.h>

void publishReading() {
  char payload[200];
  snprintf(payload, sizeof(payload),
    "{\"temp\":%.1f,\"hum\":%.1f,\"co2\":%d,\"gas\":%d}",
    bme.readTemperature(), bme.readHumidity(),
    map(mq135Air, 0, 4095, 400, 2000), map(mq2Air, 0, 4095, 0, 100));
  mqtt.publish("home/air/sensor", payload);
}

A Raspberry Pi running Node-RED can graph these over time.

What you learned

  • A complete indoor air quality monitor with 4 sensors and an OLED.
  • All sensors share the I2C bus for the BME280 and OLED.
  • MQ sensors need 24 hours of warm-up for stable readings.
  • The CO2 value from MQ-135 is approximate; use a dedicated CO2 sensor for accurate readings.

What to build next

  • The BME280 tutorial covers the climate sensor.
  • The MQ-2 tutorial covers the gas sensor (and its caveats).
  • The OLED tutorial covers the display.
  • The book ESP32 Smart Home has more air quality patterns (ventilation control, multi-room monitoring).

Chapter 48

ESP32: build a solar-powered trail camera

esp32 · 60 min

A trail camera: ESP32-CAM takes a photo when the PIR motion sensor fires, saves to SD card, and goes back to sleep. Solar + battery means it runs indefinitely in the field.

This is the project that ties the camera (built-in to ESP32-CAM), PIR motion, SD card, deep sleep, and solar+battery tutorials together.

What you need

  • ESP32-CAM module (about $10)
  • PIR motion sensor (HC-SR501)
  • microSD card (8-32 GB)
  • microSD card adapter for the ESP32-CAM (usually ships with the board)
  • 18650 + TP4056 + LDO (from the battery tutorial)
  • Small solar panel (1-2W, 6V)
  • Schottky diode (1N5817)
  • Weatherproof enclosure (IP65 or better)
  • FTDI USB-serial adapter (for programming the ESP32-CAM; it has no USB)

Wiring

ESP32-CAM 5V -- TP4056 OUT+ (via diode from solar panel)
ESP32-CAM GND -- TP4056 OUT- (common ground)
ESP32-CAM 3.3V -- (internal)

PIR VCC -- ESP32-CAM 5V
PIR GND -- ESP32-CAM GND
PIR OUT -- ESP32-CAM GPIO 13

microSD -- ESP32-CAM built-in slot

The ESP32-CAM has built-in microSD support. Insert a card formatted as FAT32.

Programming the ESP32-CAM

The ESP32-CAM does not have a USB port. You need an FTDI adapter:

FTDI 5V  -- ESP32-CAM 5V
FTDI GND -- ESP32-CAM GND
FTDI TX  -- ESP32-CAM RX (GPIO 3)
FTDI RX  -- ESP32-CAM TX (GPIO 1)

Set the FTDI to 3.3V mode. Connect GPIO 0 to GND during power-up to enter download mode. After upload, disconnect GPIO 0 from GND.

The code

#include "esp_camera.h"
#include "SD_MMC.h"
#include "esp_sleep.h"

// ESP32-CAM pin definitions (AI-Thinker model)
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

#define PIR_PIN 13

void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(PIR_PIN, INPUT);

  // Initialize camera
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_UXGA;   // 1600x1200
  config.jpeg_quality = 10;
  config.fb_count = 1;

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed: 0x%x\n", err);
    ESP.restart();
  }

  // Initialize SD card
  if (!SD_MMC.begin()) {
    Serial.println("SD card init failed");
    ESP.restart();
  }

  Serial.println("Trail camera ready");
  Serial.println("Press the BOOT button to take a test photo");
}

void loop() {
  if (digitalRead(PIR_PIN) == HIGH) {
    Serial.println("Motion detected, capturing");
    takePhoto();
    delay(2000);   // debounce
  }

  // Sleep for 30 seconds if no motion
  esp_sleep_enable_ext0_wakeup(PIR_PIN, 1);
  esp_sleep_enable_timer_wakeup(30 * 1000000ULL);
  esp_deep_sleep_start();
}

void takePhoto() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Camera capture failed");
    return;
  }

  // Generate filename: /sdcard/IMG_001.jpg
  static int photoCount = 0;
  photoCount++;
  char filename[32];
  snprintf(filename, sizeof(filename), "/sdcard/IMG_%03d.jpg", photoCount);

  File file = SD_MMC.open(filename, FILE_WRITE);
  if (!file) {
    Serial.printf("Failed to open %s\n", filename);
    esp_camera_fb_return(fb);
    return;
  }
  file.write(fb->buf, fb->len);
  file.close();
  esp_camera_fb_return(fb);

  Serial.printf("Saved %s (%d bytes)\n", filename, fb->len);
}

Upload. Press the BOOT button (or trigger the PIR). The ESP32-CAM takes a photo, saves it as IMG_001.jpg on the SD card, and goes back to sleep.

The SD card capacity

A 32 GB SD card holds about 20,000 JPEG photos at UXGA resolution (1600x1200). At 1 photo per hour (or per motion event), that is about 2 years of storage.

For higher resolution, use the OV2640's full 5MP mode (FRAMESIZE_QSXGA). File size per photo doubles.

The battery math

The ESP32-CAM takes about 1 second to wake, capture, save, and sleep. During that 1 second, it draws about 200 mA. At rest, it draws about 20 mA in deep sleep.

For 10 photos per day:

  • Wake time: 10 * 1 = 10 seconds at 200 mA = 0.6 mAh
  • Sleep time: 86390 seconds at 20 mA = 480 mAh

Wait, that's wrong. 20 mA in deep sleep is way too high. Let me redo the math:

  • Wake time: 10 * 1 second = 10 seconds. 200 mA peak.
  • Sleep time: 86390 seconds at 0.5 mA (ESP32-CAM deep sleep with PIR active).

Per day: (10 * 200 / 3600) + (86390 * 0.5 / 3600) = 0.55 + 12 = 12.5 mAh.

A 2500 mAh 18650 lasts about 200 days without solar. A 1W solar panel in 4 sun-hours provides 200 mAh per day, which is enough.

The weatherproof enclosure

The ESP32-CAM and PIR need weather protection:

  • IP65 or IP67 enclosure (the kind for outdoor electrical boxes)
  • Camera lens opening (use a clear window or the lens through a hole)
  • PIR sensor exposed (use a waterproof PIR with a clear lens cover)
  • Cable glands for any external wires

A standard outdoor junction box with a clear lid works. Mount the camera with the lens through a hole in the box, sealed with silicone.

What you learned

  • A complete solar-powered trail camera.
  • ESP32-CAM with camera, PIR, SD card, and deep sleep.
  • Solar + battery for perpetual operation.
  • 200+ days runtime on a single 18650 with solar.

What to build next

  • The PIR motion tutorial covers the sensor.
  • The 18650 + TP4056 tutorial covers the battery.
  • The solar + battery tutorial covers the perpetual power.
  • The book ESP32 Camera Projects covers image processing, motion detection in software, and remote viewing.

Chapter 49

ESP32: build a 2WD robot base with motor control

esp32 · 60 min

A 2WD (two-wheel drive) robot base. Two DC motors, an L298N motor driver, an ESP32 controller, ultrasonic distance sensors for obstacle avoidance. The base is the foundation; you can add a sensor, an arm, or a camera on top.

This is the project that ties the HC-SR04, LEDC PWM, and BME280 (well, not BME280, but other sensors) tutorials together.

What you need

  • ESP32 dev board
  • 2WD robot chassis (the kit with motors, wheels, and a platform; about $10-20)
  • L298N motor driver module (the standard H-bridge; about $2)
  • 2 HC-SR04 ultrasonic distance sensors (front and back; about $2 each)
  • 18650 battery pack (7.4V, 2 cells in series; about $10)
  • Wires, screws, hot glue

Wiring

L298N IN1 -- ESP32 GPIO 25
L298N IN2 -- ESP32 GPIO 26
L298N IN3 -- ESP32 GPIO 27
L298N IN4 -- ESP32 GPIO 14
L298N ENA -- ESP32 GPIO 32 (PWM channel for left motor)
L298N ENB -- ESP32 GPIO 33 (PWM channel for right motor)

L298N +12V -- 7.4V battery +
L298N GND  -- ESP32 GND, battery -

HC-SR04 (front) TRIG -- ESP32 GPIO 5
HC-SR04 (front) ECHO -- ESP32 GPIO 18 (use voltage divider: 1k + 2k ohm)
HC-SR04 (back)  TRIG -- ESP32 GPIO 19
HC-SR04 (back)  ECHO -- ESP32 GPIO 23 (use voltage divider)

The L298N's 5V logic output can power the ESP32's 5V pin (with a diode or voltage regulator for safety). Or power the ESP32 from a separate 5V regulator.

The code

const int IN1 = 25;
const int IN2 = 26;
const int IN3 = 27;
const int IN4 = 14;
const int ENA = 32;
const int ENB = 33;

const int FRONT_TRIG = 5;
const int FRONT_ECHO = 18;
const int BACK_TRIG = 19;
const int BACK_ECHO = 23;

const int FRONT_OBSTACLE_DISTANCE = 25;   // cm
const int BACK_OBSTACLE_DISTANCE = 15;
const unsigned long OBSTACLE_CHECK_INTERVAL = 100;
const int MOTOR_SPEED = 200;   // 0-255

unsigned long lastObstacleCheck = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);
  pinMode(ENA, OUTPUT);
  pinMode(ENB, OUTPUT);

  pinMode(FRONT_TRIG, OUTPUT);
  pinMode(FRONT_ECHO, INPUT);
  pinMode(BACK_TRIG, OUTPUT);
  pinMode(BACK_ECHO, INPUT);

  // Setup LEDC PWM at 25 kHz for the motors (above audible)
  ledcSetup(0, 25000, 8);   // ENA on channel 0
  ledcSetup(1, 25000, 8);   // ENB on channel 1
  ledcAttachPin(ENA, 0);
  ledcAttachPin(ENB, 1);
}

void loop() {
  if (millis() - lastObstacleCheck > OBSTACLE_CHECK_INTERVAL) {
    lastObstacleCheck = millis();
    int frontDist = readDistance(FRONT_TRIG, FRONT_ECHO);
    int backDist = readDistance(BACK_TRIG, BACK_ECHO);

    if (frontDist < FRONT_OBSTACLE_DISTANCE) {
      stopMotors();
      reverse();
      delay(500);
      turnRight();
      delay(400);
    } else if (backDist < BACK_OBSTACLE_DISTANCE) {
      stopMotors();
      forward();
      delay(500);
      turnLeft();
      delay(400);
    } else {
      forward();
    }
  }
}

int readDistance(int trigPin, int echoPin) {
  digitalWrite(trigPin, LOW);
  delayMicroseconds(2);
  digitalWrite(trigPin, HIGH);
  delayMicroseconds(10);
  digitalWrite(trigPin, LOW);
  long duration = pulseIn(echoPin, HIGH, 30000);
  if (duration == 0) return 999;   // out of range
  return duration * 0.0343 / 2;
}

void forward() {
  digitalWrite(IN1, HIGH);
  digitalWrite(IN2, LOW);
  digitalWrite(IN3, HIGH);
  digitalWrite(IN4, LOW);
  ledcWrite(0, MOTOR_SPEED);
  ledcWrite(1, MOTOR_SPEED);
}

void reverse() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, HIGH);
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, HIGH);
  ledcWrite(0, MOTOR_SPEED);
  ledcWrite(1, MOTOR_SPEED);
}

void turnRight() {
  digitalWrite(IN1, HIGH);
  digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, HIGH);
  ledcWrite(0, MOTOR_SPEED);
  ledcWrite(1, MOTOR_SPEED);
}

void turnLeft() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, HIGH);
  digitalWrite(IN3, HIGH);
  digitalWrite(IN4, LOW);
  ledcWrite(0, MOTOR_SPEED);
  ledcWrite(1, MOTOR_SPEED);
}

void stopMotors() {
  ledcWrite(0, 0);
  ledcWrite(1, 0);
}

Upload. Place the robot on the floor. It should drive forward, stop when it sees an obstacle, reverse, turn right, and continue.

The "where am I going" problem

The obstacle-avoidance pattern above is a random walk: the robot bounces off things. It will eventually reach most areas but takes a long time.

For real navigation:

  • Wall following: use one sensor to track a wall on the left or right.
  • Mapping: record where obstacles are and build a map.
  • GPS / outdoor navigation: use a GPS module for outdoor waypoints.

The book ESP32 Robotics Projects covers SLAM (Simultaneous Localization and Mapping) on the ESP32.

The "stuck in a corner" problem

If the robot gets stuck in a corner, it can keep reversing and turning without escaping. Add an escape pattern: if the robot has been reversing for more than 3 times in a row, do a 180-degree turn.

int reverseCount = 0;

void loop() {
  // ...
  if (frontDist < FRONT_OBSTACLE_DISTANCE) {
    stopMotors();
    reverse();
    delay(500);
    reverseCount++;
    if (reverseCount > 3) {
      turnRight();
      delay(800);   // turn 180
      reverseCount = 0;
    } else {
      turnRight();
      delay(400);
    }
  } else {
    reverseCount = 0;
    forward();
  }
}

The battery

A 7.4V LiPo or 2x 18650 pack is the right choice. The L298N's motor supply takes 5-35V, so 7.4V is in range. The motors typically draw 200-500 mA each, so the pack needs to supply at least 1A.

Battery life: about 1-2 hours of continuous driving. For longer runs, use a larger battery or sleep the motors between movements.

What you learned

  • A 2WD robot base with obstacle avoidance.
  • The L298N motor driver for bidirectional motor control.
  • LEDC PWM at 25 kHz for silent motor operation.
  • The "stuck in a corner" escape pattern.

What to build next

  • The HC-SR04 tutorial covers the distance sensor.
  • The LEDC PWM tutorial covers the motor speed control.
  • The book ESP32 Robotics Projects covers the full robotics stack (sensors, motors, mapping, navigation).

Chapter 50

ESP32: build a Wi-Fi doorbell that pushes to your phone

esp32 · 45 min

A Wi-Fi doorbell that pushes a notification to your phone when someone presses the button. Replaces a smart doorbell ($50-100) with $5 of parts.

This is the project that ties the button debounce, Wi-Fi, and HTTPS tutorials together.

What you need

  • ESP32 dev board
  • Momentary pushbutton (the four-leg tactile kind)
  • 10k ohm pull-up resistor (or use the internal pull-up; see code)
  • USB power supply (the ESP32 stays powered; no battery)

Wiring

ESP32 GPIO 4 -- button -- GND

That's the entire wiring. The internal pull-up resistor holds GPIO 4 HIGH when the button is not pressed. When pressed, GPIO 4 goes LOW.

The push notification service

There are several options:

  • Pushover ($5 one-time per platform): the standard for home projects. Simple HTTPS API.
  • Telegram Bot API (free): send a message to a Telegram group or bot.
  • IFTTT Webhooks (free tier): trigger an applet that sends an SMS or push.
  • ntfy.sh (free, open source): self-hosted or use their public server.

For this tutorial, I'll use Pushover because it is the simplest API and works on iOS and Android.

Setting up Pushover

  1. Sign up at https://pushover.net.
  2. Note your user key.
  3. Create an application at https://pushover.net/apps/build and note the API token.

The code

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* ssid = "your-wifi";
const char* password = "your-password";

const char* pushoverToken = "your-api-token";
const char* pushoverUser = "your-user-key";

const int BUTTON_PIN = 4;
const unsigned long DEBOUNCE_MS = 50;

unsigned long lastPress = 0;
bool wasPressed = false;

void setup() {
  Serial.begin(115200);
  delay(1000);

  pinMode(BUTTON_PIN, INPUT_PULLUP);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.print("Connected. IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  bool pressed = digitalRead(BUTTON_PIN) == LOW;

  if (pressed && !wasPressed && (millis() - lastPress > DEBOUNCE_MS)) {
    lastPress = millis();
    Serial.println("Button pressed, sending notification");
    sendNotification();
  }

  wasPressed = pressed;
  delay(20);
}

void sendNotification() {
  WiFiClientSecure *client = new WiFiClientSecure();
  client->setInsecure();   // skip certificate validation (for prototyping)

  HTTPClient http;
  http.begin(*client, "https://api.pushover.net/1/messages.json");

  String payload = "token=";
  payload += pushoverToken;
  payload += "&user=";
  payload += pushoverUser;
  payload += "&title=Doorbell&message=Someone is at the door";

  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  int httpCode = http.POST(payload);

  if (httpCode == 200) {
    Serial.println("Notification sent");
  } else {
    Serial.print("Failed: ");
    Serial.println(httpCode);
  }

  http.end();
}

Upload. Press the button. Within a few seconds, you should get a notification on your phone.

The security caveats

The code uses setInsecure() for simplicity, which means the ESP32 trusts any TLS certificate. For a real doorbell, use setCACert() with the proper Pushover root certificate (see the HTTPS tutorial).

The Pushover API token and user key are in the firmware. If someone gets physical access to the ESP32, they can read them. For real projects, store them in NVS (Non-Volatile Storage) and accept that they can be extracted.

The "double-press" patterns

For more complex interactions (e.g. one button for doorbell, hold for 3 seconds for alarm), add timing:

unsigned long pressStart = 0;

void loop() {
  bool pressed = digitalRead(BUTTON_PIN) == LOW;

  if (pressed && !wasPressed) {
    pressStart = millis();
  } else if (!pressed && wasPressed) {
    unsigned long duration = millis() - pressStart;
    if (duration > 3000) {
      Serial.println("Long press: ALARM");
      sendAlert();
    } else if (duration > 50) {
      Serial.println("Short press: doorbell");
      sendNotification();
    }
  }

  wasPressed = pressed;
  delay(20);
}

Single press = doorbell. Hold for 3+ seconds = alarm.

The "always-on" challenge

The ESP32 stays powered continuously. Power consumption: about 50 mA average (mostly Wi-Fi keep-alive). At 120V mains, that is about 1 kWh per month, or $0.15.

For battery operation, use deep sleep between presses:

void sendNotification() {
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Send notification
  sendNotificationInternal();

  WiFi.disconnect();

  // Sleep until button press
  esp_sleep_enable_ext0_wakeup(BUTTON_PIN, 0);   // wake when button pressed (LOW)
  esp_deep_sleep_start();
}

void loop() {
  // Not reached; wakes from sleep and sends notification, then sleeps again
}

Battery life on a 18650: about 6 months with one press per day.

What you learned

  • A working Wi-Fi doorbell for $5 of parts.
  • Push notifications via Pushover (or Telegram, IFTTT, ntfy.sh).
  • The "always-on" and "battery" patterns.
  • The single/double press pattern for richer interactions.

What to build next

  • The button debounce tutorial covers the input side.
  • The HTTPS tutorial covers the API call side.
  • The book ESP32 Smart Home covers multiple-input smart devices (doorbell + camera, motion + doorbell).

Chapter 51

ESP32: add 16 GPIO pins with the MCP23017 I2C port expander

esp32 · 25 min

The MCP23017 is the part I reach for when an ESP32 project runs out of GPIO. It gives you 16 more GPIO pins over I2C, using only 2 of the ESP32's own pins (SDA and SCL). The whole chip is in a DIP package that fits on a breadboard, costs about $2, and works on 3.3V or 5V.

The pattern I use most: an ESP32 with a BME280, an OLED, and a 4-relay board. That fills the I2C bus and uses maybe 8 GPIO pins. Add an MCP23017 and I have 16 more GPIOs for buttons, LEDs, or relay expansion, without giving up any of the originals.

What you need

  • ESP32 dev board (or Arduino Uno/Nano, or Pico, or Pi)
  • MCP23017 chip (the DIP-28 package on a breakout board is the easiest to breadboard; about $2)
  • 2x 4.7kohm pull-up resistors for SDA and SCL (most breakout boards include them, but check)
  • Breadboard and jumper wires

The MCP23017 is one of two common Microchip port expanders. The other is the MCP23008 (8 pins). The '017 is the more useful one because you can chain up to 8 of them on the same I2C bus for 128 GPIOs total.

Wiring

The MCP23017 uses I2C. Same two wires as every other I2C device.

MCP23017 VDD  -- ESP32 3.3V
MCP23017 VSS  -- ESP32 GND
MCP23017 SDA  -- ESP32 GPIO 21
MCP23017 SCL  -- ESP32 GPIO 22
MCP23017 A0   -- ESP32 GND  (I2C address 0x20; see table)
MCP23017 A1   -- ESP32 GND
MCP23017 A2   -- ESP32 GND
MCP23017 RESET -- ESP32 3.3V (tie high; the chip resets if it floats)

The A0/A1/A2 pins set the I2C address. Tie them to GND or VDD to pick one of 8 addresses:

A2 A1 A0 I2C address
GND GND GND 0x20
GND GND VDD 0x21
GND VDD GND 0x22
GND VDD VDD 0x23
VDD GND GND 0x24
VDD GND VDD 0x25
VDD VDD GND 0x26
VDD VDD VDD 0x27

That is how you put 8 MCP23017 chips on the same bus. The book version of this build (4 relay boards, 4 sensor arrays, 16 LEDs) uses 3 expanders on addresses 0x20, 0x21, 0x22.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search Adafruit MCP23017 Arduino Library. Install it.

The code

ESP32 (Arduino)

#include <Wire.h>
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp;

void setup() {
  Serial.begin(115200);
  Wire.begin();
  mcp.begin(0x20);   // address from the A0/A1/A2 table

  // Pins 0-7 as outputs, pins 8-15 as inputs with pull-ups
  for (int i = 0; i < 8; i++) {
    mcp.pinMode(i, OUTPUT);
  }
  for (int i = 8; i < 16; i++) {
    mcp.pinMode(i, INPUT_PULLUP);
  }
}

void loop() {
  // Blink the first 4 outputs
  for (int i = 0; i < 4; i++) {
    mcp.digitalWrite(i, HIGH);
  }
  delay(500);
  for (int i = 0; i < 4; i++) {
    mcp.digitalWrite(i, LOW);
  }
  delay(500);

  // Read the 8 input pins and print them as a byte
  uint8_t inputs = 0;
  for (int i = 0; i < 8; i++) {
    if (mcp.digitalRead(i + 8)) {
      inputs |= (1 << i);
    }
  }
  Serial.print("Inputs: 0x");
  Serial.println(inputs, HEX);
}

Arduino (Uno, Nano, Mega)

The code is identical. Wire.begin() picks the right I2C pins per board (A4/A5 on the Uno, GPIO 21/22 on the ESP32).

#include <Wire.h>
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp;

void setup() {
  Serial.begin(9600);
  Wire.begin();
  mcp.begin(0x20);

  for (int i = 0; i < 16; i++) {
    mcp.pinMode(i, OUTPUT);
  }
}

void loop() {
  for (int i = 0; i < 16; i++) {
    mcp.digitalWrite(i, HIGH);
  }
  delay(500);
  for (int i = 0; i < 16; i++) {
    mcp.digitalWrite(i, LOW);
  }
  delay(500);
}

The MCP23017 is 5V tolerant on its I2C lines even when powered from 3.3V. The Adafruit library uses the Wire library, which on a 5V Arduino pulls SDA/SCL to 5V. On the ESP32 (3.3V) this is fine, but if you mix 5V and 3.3V devices on the same I2C bus, use a level shifter.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import time

# ESP32: GPIO 21 (SDA), 22 (SCL)
# Pico: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000)

MCP_ADDR = 0x20

# IODIR register: 1 = input, 0 = output
# IODIRA = 0x00, IODIRB = 0x01
i2c.writeto_mem(MCP_ADDR, 0x00, b'\x00')   # GPA all outputs
i2c.writeto_mem(MCP_ADDR, 0x01, b'\xff')   # GPB all inputs (with pull-ups)

# GPPU register: enable pull-ups on GPB
i2c.writeto_mem(MCP_ADDR, 0x0D, b'\xff')

print("MCP23017 ready at 0x{:02x}".format(MCP_ADDR))

while True:
    i2c.writeto_mem(MCP_ADDR, 0x12, b'\x0f')   # OLATA = 0b00001111
    time.sleep(0.5)
    i2c.writeto_mem(MCP_ADDR, 0x12, b'\x00')   # OLATA = 0
    time.sleep(0.5)

    # Read GPB (input port)
    inb = i2c.readfrom_mem(MCP_ADDR, 0x13, 1)[0]
    print("Inputs: 0x{:02x}".format(inb))

The MCP23017 register map: 0x12 = OLATA (output latch A), 0x13 = GPIOA (read input A), 0x14 = OLATB, 0x15 = GPIOB. The library handles all of this; the bare-register version above is for when you cannot install a library (e.g. on a constrained Pico build).

Raspberry Pi Python

import smbus2
import time

bus = smbus2.SMBus(1)
MCP_ADDR = 0x20

# IODIRA = 0x00, all outputs
bus.write_byte_data(MCP_ADDR, 0x00, 0x00)
# IODIRB = 0x01, all inputs
bus.write_byte_data(MCP_ADDR, 0x01, 0xFF)
# GPPUA = 0x0C, pull-ups on port A
bus.write_byte_data(MCP_ADDR, 0x0C, 0xFF)

print("MCP23017 ready")

while True:
    bus.write_byte_data(MCP_ADDR, 0x12, 0x0F)
    time.sleep(0.5)
    bus.write_byte_data(MCP_ADDR, 0x12, 0x00)
    time.sleep(0.5)

Enable I2C on the Pi first: sudo raspi-config >> Interface Options

I2C >> Enable. Then pip3 install smbus2 if not already installed.

What you should see

Open the Serial Monitor at 115200 baud. The first 4 outputs (GPA0 through GPA3) blink on and off every 500 ms. The 8 input pins (GPB0 through GPB7) print their state as a hex byte every cycle. Wire a jumper from GPB0 to GND, the byte changes to 0xFE (bit 0 cleared). Pull it to 3.3V, it goes back to 0xFF.

I2C speed

The MCP23017 supports I2C at 100 kHz (standard), 400 kHz (fast), and 1.7 MHz (high-speed). The ESP32's default Wire library is 100 kHz; the MCP23017 is faster than most I2C devices. For a project with 16 fast inputs (e.g. a 16-channel button matrix), bump the clock:

Wire.setClock(400000);

For 16 buttons polled at 100 Hz, the faster clock cuts the I/O time from 1.6 ms to 0.4 ms per scan. Matters when you are doing real-time work; does not matter for indicator LEDs.

Interrupts (the INT pin)

The MCP23017 has an INT pin that goes LOW when an input pin changes state (if you enable it). That is the right way to do "wait for button press" without polling the bus in a tight loop.

mcp.setupInterrupts(true, false, LOW);   // mirror, open-drain, active-low
mcp.enableInterruptPin(8, CHANGE);        // pin 8, fire on either edge
attachInterrupt(digitalPinToInterrupt(15), buttonISR, FALLING);

void buttonISR() {
  uint8_t port = mcp.getCapturedInterrupt();
  Serial.print("Interrupt on pin: ");
  Serial.println(port);
}

The getCapturedInterrupt() returns the port value at the moment of the interrupt. This is the pattern for low-power projects where the ESP32 sleeps until a button is pressed. The MCP23017 holds the INT pin LOW while the ESP32 is asleep; the ESP32 wakes, reads the captured value, and goes back to sleep.

What you learned

  • The MCP23017 adds 16 GPIOs over I2C using only 2 pins.
  • A0/A1/A2 set the address. 8 chips per bus = 128 GPIOs.
  • The Adafruit library handles all the register math.
  • The INT pin is the way to do low-power button inputs.

When something breaks

  • I2C scanner does not find 0x20. Check A0/A1/A2 wiring (they must be tied, not floating). Check RESET is tied to VDD, not floating. A floating reset will sometimes work, sometimes not.
  • Outputs toggle but inputs read 0xFF always. Pull-ups are not enabled. Set the GPPU register (0x0C / 0x0D) to 0xFF for inputs.
  • Inputs read random values. Long wires. Add 100nF capacitor across VDD/VSS at the chip. The MCP23017 is sensitive to noise on the supply.
  • Bus errors after a few hours. I2C bus is locked up. Add a Wire.reset() or a hardware watchdog (the TP-Link "I2C bus reset" trick: pulse SCL 9 times manually).

What to build next

  • The shift register tutorial is the other way to expand outputs. Use that when you do not need inputs.
  • The relay module tutorial combined with this gives you 16 switched outputs from 2 ESP32 pins.
  • The book ESP32 Smart Home has a 64-output lighting controller built from 4 MCP23017 chips.

Chapter 52

ESP32: drive a MAX7219 8-digit 7-segment display

esp32 · 25 min

The MAX7219 is the chip that drives every "8-digit 7-segment display module" you see on Amazon. The breakout has the chip, the 8 digits, and the current-limiting resistors all in one board. It speaks SPI, which means 3 wires from the ESP32, and you can chain multiple displays by daisy-chaining the DOUT of one to the DIN of the next.

I use these for counters, clocks, temperature displays, anything that needs to show a number from across the room. The OLED is better for graphs and text; the MAX7219 is better for big readable digits.

What you need

  • ESP32 dev board (or Arduino, Pico, Pi)
  • MAX7219 8-digit 7-segment display module (the common one with 5-pin header: VCC, GND, DIN, CS, CLK; about $3-5)
  • 5 jumper wires

Buy the "common cathode" version, which is the standard MAX7219 module. The "TM1637" 4-digit displays look similar but use a different (and less capable) chip; that one is its own tutorial.

Wiring

The MAX7219 uses SPI. The default SPI pins on the ESP32 are GPIO 18 (SCK), 19 (MISO, not used here), and 23 (MOSI, which becomes DIN on the display).

MAX7219 VCC -- ESP32 5V  (the module is 5V; logic is 3.3V-tolerant)
MAX7219 GND -- ESP32 GND
MAX7219 DIN -- ESP32 GPIO 23  (MOSI)
MAX7219 CS  -- ESP32 GPIO 5   (chip select, also called LOAD)
MAX7219 CLK -- ESP32 GPIO 18  (SCK)

The MAX7219 is a 5V chip, but its logic inputs are 3.3V-tolerant. The 5V VCC powers the LEDs; the 3.3V signals from the ESP32 drive the chip correctly.

The module has an ISET resistor on the back (usually labeled R1). It sets the LED current. The default is 10kohm, which is fine for indoor use. For outdoor or high-brightness, replace it with a smaller value (see "Brightness" below).

Install libraries

Sketch >> Include Library >> Manage Libraries >> search LedControl (by Eberhard Fahle). Install it. This is the standard library for MAX7219 with Arduino.

The code

ESP32 (Arduino)

#include <LedControl.h>

// DIN, CLK, CS, number of daisy-chained modules
LedControl lc = LedControl(23, 18, 5, 1);

void setup() {
  lc.shutdown(0, false);     // wake up the display
  lc.setIntensity(0, 8);     // brightness 0-15
  lc.clearDisplay(0);
}

void loop() {
  // Show a counter from 0 to 99999999
  for (long i = 0; i <= 99999999; i++) {
    lc.setNumber(0, i, false);   // false = no leading zeros
    delay(50);
    if (i >= 12345) break;       // stop early for the demo
  }

  // Show a static temperature
  lc.clearDisplay(0);
  lc.setChar(0, 7, 'C', false);   // 'C' on the rightmost digit
  lc.setNumber(0, 234, false);    // "234" on the left
  delay(2000);

  // Show a message (limited to 8 chars)
  lc.clearDisplay(0);
  lc.setRow(0, 0, 0x7E);   // 'b'
  lc.setRow(0, 1, 0x30);   // 'r'
  lc.setRow(0, 2, 0x79);   // 'E'
  lc.setRow(0, 3, 0x7C);   // 'd'
  delay(2000);
}

Arduino (Uno, Nano, Mega)

Same code, but the default SPI pins are 11 (MOSI) and 13 (SCK). The LedControl library lets you set them explicitly:

#include <LedControl.h>

// For Uno/Nano: DIN=11, CLK=13, CS=10
LedControl lc = LedControl(11, 13, 10, 1);

void setup() {
  lc.shutdown(0, false);
  lc.setIntensity(0, 8);
  lc.clearDisplay(0);
}

void loop() {
  lc.setNumber(0, 12345, false);
  delay(1000);
  lc.clearDisplay(0);
  delay(1000);
}

The 5V Arduino drives the MAX7219's logic at 5V, which is exactly what it wants. No level shifting needed.

MicroPython (ESP32 or Pico)

from machine import Pin, SPI
import time

# ESP32: SCK=18, MOSI=23; CS=5
# Pico: SCK=2, MOSI=3; CS=5
spi = SPI(0, sck=Pin(18), mosi=Pin(23), baudrate=10_000_000)
cs = Pin(5, Pin.OUT)

cs.value(1)

def send(cmd, data):
    cs.value(0)
    spi.write(bytes([cmd, data]))
    cs.value(1)

def init_display():
    send(0x0C, 0x01)   # shutdown register: normal operation
    send(0x0F, 0x00)   # display test: off
    send(0x0B, 0x07)   # scan limit: all 8 digits
    send(0x0A, 0x08)   # intensity: 8/16
    send(0x09, 0x00)   # decode mode: no decode (we use raw segments)

def clear():
    for d in range(1, 9):
        send(d, 0x00)

def show_number(n):
    """Show a number, right-aligned, up to 8 digits."""
    s = f"{n:08d}"
    for d, ch in enumerate(s):
        digit = int(ch)
        # digit patterns 0-9 (no decode mode)
        patterns = [0x7E, 0x30, 0x6D, 0x79, 0x33, 0x5B, 0x5F, 0x70, 0x7F, 0x7B]
        send(d + 1, patterns[digit])

init_display()
clear()
print("MAX7219 ready")

n = 0
while True:
    show_number(n)
    n = (n + 1) % 100000000
    time.sleep(0.1)

The MicroPython version is verbose because there is no LedControl library port. The patterns array is the 7-segment encoding for digits 0-9; the same encoding the MAX7219's "decode mode" would do in hardware. Without decode mode, the ESP32 sends raw segment bytes.

Raspberry Pi Python

import spidev
import time

spi = spidev.SpiDev()
spi.open(0, 0)
spi.max_speed_hz = 10_000_000

def send(cmd, data):
    spi.xfer2([cmd, data])

def init_display():
    send(0x0C, 0x01)
    send(0x0F, 0x00)
    send(0x0B, 0x07)
    send(0x0A, 0x08)
    send(0x09, 0x00)

def clear():
    for d in range(1, 9):
        send(d, 0x00)

def show_number(n):
    s = f"{n:08d}"
    patterns = [0x7E, 0x30, 0x6D, 0x79, 0x33, 0x5B, 0x5F, 0x70, 0x7F, 0x7B]
    for d, ch in enumerate(s):
        send(d + 1, patterns[int(ch)])

init_display()
clear()

n = 0
while True:
    show_number(n)
    n = (n + 1) % 100000000
    time.sleep(0.1)

Enable SPI on the Pi: sudo raspi-config >> Interface Options >> SPI

Enable. Then pip3 install spidev.

What you should see

A counter that increments from 0 to 12345, then shows "234C" (degrees Celsius), then shows "bred" (B-R-E-D in a custom font, this is the demo for raw segment control). Each message holds for 2 seconds.

If you see only one digit, the scan limit register is wrong. Set it to 7 (8 digits) using send(0x0B, 0x07).

Daisy-chaining multiple modules

The MAX7219 has a DOUT pin. Wire it to the DIN of the next module. Both share the same CLK and CS lines. The LedControl library takes the count of daisy-chained modules as the 4th argument:

LedControl lc = LedControl(23, 18, 5, 4);   // 4 daisy-chained modules

To write to module index 2 (the 3rd in the chain):

lc.setNumber(2, 42, false);   // show 42 on the 3rd module

The MAX7219 supports up to 8 modules per chain. That is 64 digits, which is enough for most clocks and large counters.

Brightness

The setIntensity value (0-15) controls brightness. The hardware limit is set by the ISET resistor:

  • 10 kohm (default): max current ~40 mA per segment
  • 5 kohm: ~80 mA per segment (bright but hot)
  • 20 kohm: ~20 mA per segment (dim, good for battery projects)

For an outdoor clock, swap to 5 kohm and run at intensity 12-15. For indoor, leave the resistor alone and use intensity 4-8 (lower is better for the eyes at night).

Showing negative numbers and decimals

setNumber does not show negative numbers. To show "-40" (a freezer alarm), use the raw segment byte for the minus sign (0x01) and write it directly to a specific digit:

lc.setRow(0, 0, 0x01);    // minus sign on the leftmost digit
lc.setDigit(0, 1, 4, false);
lc.setDigit(0, 2, 0, false);

For decimals, write the digit and the decimal point (0x80) to the same row:

lc.setDigit(0, 5, 2, true);   // '2.' on digit 5 (decimal point on)
lc.setDigit(0, 6, 7, false);  // '7' on digit 6

What you learned

  • MAX7219 is the standard chip for 8-digit 7-segment displays.
  • 3 wires for SPI (DIN, CLK, CS). Daisy-chain up to 8 modules.
  • The LedControl library handles all the register-level work.
  • Brightness is software-controllable (intensity 0-15) and hardware- settable (the ISET resistor).

When something breaks

  • Display is completely dark. shutdown(0, false) not called, or wrong VCC (must be 5V, not 3.3V).
  • All segments light up. Display test mode is on. Send 0x0F, 0x00 to disable.
  • Garbled digits. SPI clock too high. Drop to 1 MHz: SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)).
  • One digit works, the rest are blank. scan limit register is set too low. Set to 7 for all 8 digits.
  • Display flickers when Wi-Fi is active. Add a 10uF capacitor across VCC/GND at the module. The 5V rail can sag during Wi-Fi bursts.

What to build next

  • The shift register tutorial is the lower-level way to drive a 7-segment without a driver chip. More wiring, more code, more learning.
  • The OLED tutorial is the better pick when you need to show text, not numbers.
  • The book ESP32 Smart Home has a 4-digit clock with NTP time sync built from this pattern.

Chapter 53

ESP32: drive a 74HC595 shift register for 8 extra output pins

esp32 · 30 min

The 74HC595 is the chip that has been on hobbyist projects since before the Arduino existed. It is a serial-in, parallel-out shift register. You push 8 bits in one at a time over 3 wires, then latch them to 8 output pins. The output pins hold their state until you push a new byte.

The trade vs. the MCP23017: the 74HC595 is outputs only, and you cannot read back the state. For 8 LEDs, 8 relays, or a 7-segment display, that is fine. For 16 buttons, you want the MCP23017.

I use 74HC595s when I need a handful of LEDs or relays and do not want to spend the money on a port expander. About 30 cents per chip.

What you need

  • ESP32 dev board (or Arduino Uno/Nano)
  • 74HC595 shift register chip (DIP-16 package, about 30 cents)
  • 8 LEDs + 8 220 ohm resistors, or 8 relays on a driver board, or one 7-segment display
  • 0.1uF decoupling capacitor
  • Breadboard, jumper wires

The 74HC595 is the through-hole, 5V-tolerant version. The 74HC595N (DIP package) is the one to breadboard. The 74LV595A is the 3.3V variant; it works but is less common.

Wiring

74HC595 pin 16 (VCC)  -- ESP32 5V
74HC595 pin 8  (GND)  -- ESP32 GND
74HC595 pin 10 (SRCLR) -- ESP32 5V    (tie HIGH; shift register clear, active LOW)
74HC595 pin 13 (OE)   -- ESP32 GND    (output enable, tie LOW to enable outputs)
74HC595 pin 14 (SER)  -- ESP32 GPIO 23  (serial data in, MOSI)
74HC595 pin 11 (SRCLK)-- ESP32 GPIO 18  (shift clock, SCK)
74HC595 pin 12 (RCLK) -- ESP32 GPIO 5   (latch clock, chip select)

74HC595 pins 15, 1-7 (QA-QH) -- your 8 LEDs/resistors/relays
0.1uF cap              -- 74HC595 pin 16 to pin 8 (VCC to GND, at the chip)

The decoupling capacitor is mandatory. The 74HC595 switches all 8 outputs at once, and that creates a current spike on the supply. The 0.1uF cap absorbs it. Without it, the chip can glitch or the ESP32 can brown out.

The SRCLR (shift register clear) and OE (output enable) pins are active LOW. Tie them to VCC and GND respectively unless you need to clear or disable the outputs from a GPIO.

Install libraries

None. The 74HC595 is simple enough that you can drive it with raw SPI, no library needed.

The code

ESP32 (Arduino)

#include <SPI.h>

const int LATCH_PIN = 5;

void setup() {
  SPI.begin();
  pinMode(LATCH_PIN, OUTPUT);
  digitalWrite(LATCH_PIN, LOW);
}

void writeShiftRegister(uint8_t data) {
  digitalWrite(LATCH_PIN, LOW);
  SPI.transfer(data);
  digitalWrite(LATCH_PIN, HIGH);
}

void loop() {
  // Light one LED at a time, walking across all 8
  for (int i = 0; i < 8; i++) {
    writeShiftRegister(1 << i);
    delay(200);
  }
}

The pattern 1 << i is a single bit moving across the byte. When i = 0, the byte is 0b00000001 (Q0 high). When i = 7, it is 0b10000000 (Q7 high).

Arduino (Uno, Nano, Mega)

#include <SPI.h>

const int LATCH_PIN = 10;   // any digital pin works for CS

void setup() {
  SPI.begin();
  pinMode(LATCH_PIN, OUTPUT);
}

void writeShiftRegister(uint8_t data) {
  digitalWrite(LATCH_PIN, LOW);
  SPI.transfer(data);
  digitalWrite(LATCH_PIN, HIGH);
}

void loop() {
  for (int i = 0; i < 8; i++) {
    writeShiftRegister(1 << i);
    delay(200);
  }
}

Same code; the SPI pins default to 11/12/13 on the Uno, and the CS pin is 10. Both work the same way.

Daisy-chaining

Wire QH' (pin 9) of the first 74HC595 to SER (pin 14) of the second. Share all other control lines. Now 16 bits become 16 outputs.

void writeShiftRegister16(uint16_t data) {
  digitalWrite(LATCH_PIN, LOW);
  SPI.transfer16(data);   // or two SPI.transfer() calls
  digitalWrite(LATCH_PIN, HIGH);
}

You can chain up to ~8 chips reliably. After that, the SPI clock period exceeds the 74HC595's setup time and bits start to corrupt. For 8+ outputs, use multiple latch pins or a different driver (the MCP23017, or a chain of TPIC6B595 high-current shift registers).

74HC595 vs. TPIC6B595 for relays

The 74HC595 can only source about 6 mA per output. That is fine for an LED, but a relay coil wants 50-100 mA. The TPIC6B595 is the relay- rated version: open-drain outputs that switch up to 500 mA each, up to 50V. The pinout is identical. Drop-in replacement.

For 8 relay coils, I use the TPIC6B595 with 5V signal relays. The ESP32's 3.3V is enough to drive the TPIC's logic inputs; the relay coils run on a separate 5V supply through the TPIC's drain pins.

7-segment without a driver

The 74HC595 can drive a single 7-segment display. Wire the segment pins (a-g, dp) to QA-QH and write the segment pattern:

const uint8_t SEG_4 = 0b01100110;   // 0x66
const uint8_t SEG_7 = 0b11111000;   // 0xF8 (only a-g; bit 7 is dp, off)

void loop() {
  writeShiftRegister(SEG_4);
  delay(1000);
  writeShiftRegister(SEG_7);
  delay(1000);
}

For a 4-digit display, you need 4 shift registers (one per digit) and a transistor array to multiplex the common anode/cathode. This is the project the MAX7219 was invented to replace. If you need more than one digit, the MAX7219 is the right pick.

Bit order and the SPI quirk

SPI on the ESP32 sends MSB first by default. The 74HC595 expects MSB first too. The first bit you send ends up at QH (pin 7, the last output), the last bit ends up at QA (pin 15, the first output). If you find your LEDs lighting in reverse order, either bit-reverse the byte or send it as 2 nibbles reversed.

To send LSB first (matches the visual order: bit 0 to QA):

SPI.setBitOrder(LSBFIRST);

Timing and clock speed

The 74HC595's max clock is about 25 MHz at 5V. The ESP32's default SPI clock is 80 MHz (way too fast) but SPI.transfer() in Arduino uses the SPI_CLOCK_DIV4 default which is 20 MHz on the ESP32. That is on the edge; for reliability, slow it down:

SPI.beginTransaction(SPISettings(10'000'000, MSBFIRST, SPI_MODE0));
SPI.transfer(data);
SPI.endTransaction();

10 MHz is safe. 1 MHz is safe even on long wires.

What you learned

  • The 74HC595 is 8 outputs from 3 pins, and chains for 16, 24, 32.
  • It is outputs only. No read-back.
  • 6 mA per pin is fine for LEDs. Use the TPIC6B595 for relays.
  • MSB first by default. The first bit sent ends up at the last output pin.

When something breaks

  • All 8 LEDs are dim. Missing 0.1uF decoupling cap. Add it across VCC/GND at the chip.
  • Outputs are stuck on their initial state. Latch pin not being toggled. The 74HC595 updates its outputs only on the rising edge of the latch.
  • Outputs are garbled or stuck high. SPI clock too fast. Drop to 1 MHz.
  • ESP32 resets when you write to the shift register. Power supply brownout. The 5V pin on the USB bus is shared with the USB-serial chip; the 8 LEDs can pull enough current to glitch it. Add a 100uF cap on the 5V rail, or use an external 5V supply.
  • First LED is always on. SRCLR (pin 10) is not tied HIGH. When SRCLR floats LOW, the chip clears itself.

What to build next

  • The MAX7219 tutorial is the right pick when you need 8+ digit displays.
  • The MCP23017 tutorial gives you 16 GPIOs that can also be inputs.
  • The book ESP32 in Production has a 64-channel LED driver board built from 8 TPIC6B595s.

Chapter 54

ESP32: drive a TLC5940 16-channel PWM LED driver

esp32 · 30 min

The ESP32's LEDC peripheral gives you 16 PWM channels, which sounds like a lot until you try to drive 16 RGB LEDs (48 channels) or a 16-pixel WS2812-style array plus a few servos. The TLC5940 is the chip that fills the gap. It is a 16-channel, 12-bit PWM LED driver that takes grayscale data over SPI and drives constant-current outputs.

The trade: 12-bit per channel, 4096 steps instead of the LEDC's 8-bit 256. Constant current means the chip itself limits the current through each LED; you do not need per-LED resistors. Daisy-chainable for 32, 48, 64+ channels.

I use TLC5940s when I have a project that needs more than 8 channels of smooth PWM. The LEDC is fine for motor speed control or a single RGB strip. The TLC5940 is for "I want every LED on this 16x16 matrix to fade smoothly."

What you need

  • ESP32 dev board (or Arduino Uno/Mega; the Mega is recommended because the Uno does not have enough RAM for large chains)
  • TLC5940 chip (DIP-28, about $3)
  • 16 LEDs + 2 kohm resistor for the IREF pin (sets the current)
  • 0.1uF decoupling capacitor on each TLC5940's VCC
  • Breadboard, jumper wires

The TLC5940 is a 5V chip. The ESP32's 3.3V logic works for the signal inputs; the LEDs need a separate 5V (or higher) supply. The outputs can switch up to 17V at 120 mA per channel.

Wiring

TLC5940 VCC  (pin 28) -- ESP32 5V
TLC5940 GND  (pin 1)  -- ESP32 GND
TLC5940 SIN  (pin 26) -- ESP32 GPIO 23  (serial data in, MOSI)
TLC5940 SCLK (pin 25) -- ESP32 GPIO 18  (shift clock, SCK)
TLC5940 BLANK(pin 23) -- ESP32 GPIO 5   (output blank, active HIGH)
TLC5940 XLAT (pin 24) -- ESP32 GPIO 17  (data latch)
TLC5940 GSCLK(pin 18) -- ESP32 GPIO 16  (PWM clock source, ~1 MHz)
TLC5940 DCPRG(pin 19) -- ESP32 GND     (use EEPROM dot correction, or VCC for default)
TLC5940 IREF (pin 20) -- 2 kohm resistor to GND  (sets LED current to ~20 mA)
TLC5940 VPRG (pin 27) -- ESP32 GND    (use grayscale data, not dot correction)

TLC5940 OUT0-OUT15     -- your 16 LEDs (cathode to OUT, anode to VLED)
VLED                   -- separate 5V supply (can share with ESP32 5V)

The IREF resistor is the key component. It sets the maximum current per channel:

IREF resistor Current per channel
10 kohm 3.9 mA
4.7 kohm 8.3 mA
2 kohm 19.5 mA
1 kohm 39 mA

For standard 5mm LEDs at 20 mA, use 2 kohm. For high-brightness LEDs at 50 mA, use 820 ohm (but check the TLC5940's 120 mA max per channel and 500 mA max per GND pin).

The ESP32 can supply 5V from the USB rail, but at 16 LEDs x 20 mA = 320 mA, that is too much for the USB-serial chip. Use an external 5V supply rated for at least 1A per TLC5940.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search Tlc5940 (by Alex Leone, the canonical Arduino library). Install it.

The library works for both ESP32 and Arduino, but it requires you to define which pins are used. The defaults are for an Arduino Mega; for the ESP32, override the pin definitions:

#define TLC_SIN   23
#define TLC_SCLK  18
#define TLC_BLANK 5
#define TLC_XLAT  17
#define TLC_GSCLK 16

The code

ESP32 (Arduino)

#include <Tlc5940.h>

void setup() {
  Tlc.init();
  Tlc.clear();
}

void loop() {
  static uint16_t phase = 0;
  phase += 64;   // 0-4095

  for (int i = 0; i < NUM_TLCS * 16; i++) {
    // Sine wave per channel, offset so each channel is at a different phase
    float angle = (phase + i * 256) * 0.00076699;  // 2*pi/4096/2
    uint16_t brightness = 2047 + (uint16_t)(2047.0 * sin(angle));
    Tlc.set(i, brightness);
  }

  Tlc.update();
  delay(20);
}

The Tlc.set(channel, value) takes a channel index and a 12-bit value (0-4095). Tlc.update() shifts the data to all the TLC5940 chips. The library handles all the SPI and PWM clock work.

Arduino (Uno, Nano, Mega)

Same code; the library auto-detects the board and uses the right default pins. The Mega can drive 5+ TLC5940s (80 channels); the Uno runs out of RAM around 3 chips.

#include <Tlc5940.h>

void setup() {
  Tlc.init(4095);   // max PWM value (default)
  Tlc.clear();
}

void loop() {
  for (int ch = 0; ch < NUM_TLCS * 16; ch++) {
    uint16_t value = (ch * 256 + millis()) & 0x0FFF;
    Tlc.set(ch, value);
  }
  Tlc.update();
  delay(20);
}

The TLC5940 needs a continuous PWM clock on GSCLK. The library uses a hardware timer to generate it; on the Uno, that means Timer1 is taken over. You cannot use the Servo library alongside the TLC5940 on the Uno.

Daisy-chaining

Wire SOUT (pin 17) of the first TLC5940 to SIN (pin 26) of the next. Share SCLK, BLANK, XLAT, GSCLK. Now each Tlc.set() call writes to all chips.

For N TLC5940s in a chain, the library expects you to set NUM_TLCS:

#define NUM_TLCS 2   // 2 chips = 32 channels

Channel numbering: 0-15 are the first chip, 16-31 are the second, and so on. The library handles the byte order automatically.

Why 12-bit, not 8-bit

The LEDC peripheral gives you 8-bit PWM (256 steps). At 256 steps, the lowest non-zero value is 1/256 = 0.4% of full brightness. At 12 bits, the lowest value is 1/4096 = 0.024%. That is a 16x improvement in low-end smoothness, which matters for:

  • Sunset/sunrise fades (the bottom 5% of brightness is the most visible)
  • Color mixing at low brightness
  • Dim indicator LEDs that need to fade in slowly

For a 16-bit TLC59401 variant, 65,536 steps. The improvement over 12-bit is not visually noticeable in 99% of projects.

When the LEDC is enough vs. when you need a TLC5940

The LEDC is enough when:

  • 8 or fewer channels
  • 8-bit resolution is fine (indicator LEDs, motor speed)
  • 5 kHz+ PWM frequency is OK
  • The project is one chip, no daisy chain

You need a TLC5940 (or similar) when:

  • 16+ channels of PWM
  • 12-bit resolution matters (color mixing, smooth fades)
  • The LEDs need constant-current drive (e.g. you do not want to hand-match 16 resistors)
  • Daisy-chaining is useful (the next LED project is always bigger than the current one)

What you learned

  • The TLC5940 is 16 channels of 12-bit PWM over SPI.
  • IREF resistor sets the per-channel current.
  • Daisy-chainable; the library handles the byte ordering.
  • Constant current means no per-LED resistors.

When something breaks

  • LEDs are very dim or off. IREF resistor value is wrong. For 20 mA per channel, use 2 kohm.
  • All LEDs flicker at the same rate. GSCLK pin not generating the PWM clock. Check the wiring; the library assumes ESP32 GPIO 16 can output a high-frequency clock.
  • Library fails to compile on ESP32. The default pin definitions are for the Mega. Override them in your sketch (see "Install libraries" above).
  • The first chip works, the rest are garbled. Daisy chain wiring issue. SOUT (pin 17) to SIN (pin 26). XLAT and SCLK must be shared.
  • LEDs are red when they should be off. OUT pins are not truly off. The TLC5940 has a minimum off-state current of about 1 uA. For zero light, use BLANK to disable all outputs.

What to build next

  • The PWM with LEDC tutorial covers the in-chip peripheral (cheaper, but limited to 16 channels).
  • The WS2812B tutorial is the right pick for individually- addressable RGB strips. No PWM chip needed; the data encoding is in the LED.
  • The book ESP32 in Production has a 96-channel LED art project built from 6 TLC5940s.

Chapter 55

ESP32: negotiate USB-C PD voltages with a trigger board

esp32 · 25 min

USB-C Power Delivery is the standard that lets a single USB-C charger deliver 5V, 9V, 12V, 15V, or 20V at up to 100W. Your laptop uses it to charge. So does your phone, your monitor, and most modern USB-C devices. With a PD trigger board, your ESP32 project can also use it: ask the charger for 12V, and you get 12V on a barrel jack or screw terminal, with no wall wart.

The trigger board is the small chip (FUSB302) or module (HW-715) that sits between the USB-C connector and your project. It does the PD negotiation with the charger, and outputs the requested voltage on a separate pin. Your ESP32 can either pre-configure the trigger (so it always asks for the same voltage) or talk to the trigger over I2C and request different voltages on demand.

I use these for projects that need more than 5V: LED strips, 12V fans, small motors, anything that previously needed a wall wart. The USB-C brick is the supply; the trigger board is the converter.

What you need

  • ESP32 dev board (or Arduino Uno/Nano)
  • USB-C PD trigger board. Two common options:
    • FUSB302 bare chip on a breakout (e.g. the Adafruit FUSB302 board; about $10)
    • HW-715 module (the cheap eBay/AliExpress option; about $3-5). Pick the "HW-715 PD/QC trigger" version, not the decoy-only versions.
  • USB-C cable (data + power, not charge-only)
  • USB-C charger or power bank that supports PD (any Apple, Anker, Aukey, or Raspberry Pi 4+ supply)

The HW-715 has a default requested voltage set by solder jumpers on the back. If you want the ESP32 to control the voltage dynamically, get the FUSB302 version with I2C.

Wiring (HW-715, fixed voltage)

The HW-715 has a few solder jumpers on the back that pick the requested voltage. The default is usually 12V. To change it, bridge the corresponding pads with solder.

HW-715 USB-C  -- your USB-C charger (data + power cable)
HW-715 VOUT+  -- your project's power input (12V nominal)
HW-715 VOUT-  -- your project's ground

That is the entire wiring for the trigger side. The ESP32 does not need to do anything; the trigger and charger handle the negotiation in hardware.

Wiring (FUSB302, ESP32-controlled)

The Adafruit FUSB302 board talks to the ESP32 over I2C.

FUSB302 VCC   -- ESP32 3.3V
FUSB302 GND   -- ESP32 GND
FUSB302 SDA   -- ESP32 GPIO 21
FUSB302 SCL   -- ESP32 GPIO 22
FUSB302 INT   -- ESP32 GPIO 4   (optional; interrupt pin)
FUSB302 CC1, CC2 -- USB-C connector (CC1 and CC2 pins on the
                    USB-C breakout)
FUSB302 VBUS  -- USB-C VBUS (the 5V supply pin)

You also need a USB-C breakout to bring the CC1, CC2, and VBUS pins out to a header. The trigger chip does the PD negotiation over the CC lines.

Install libraries

Sketch >> Include Library >> Manage Libraries >> search FUSB302 (or USB Power Delivery for the more capable library). Install it.

The bare FUSB302 needs a lower-level library that handles the PD state machine. The common picks are:

  • FUSB302 by Joseph Duchesne (basic, just for triggering)
  • USB Power Delivery by Joshua Bernardino (full PD stack, can negotiate any voltage on any port)

For 90% of projects, the basic FUSB302 library is enough: you request a fixed voltage (say 12V), the chip handles the rest.

The code (FUSB302, request 12V)

ESP32 (Arduino)

#include <Wire.h>
#include "FUSB302.h"

FUSB302 fusb;

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000);

  if (!fusb.begin()) {
    Serial.println("FUSB302 not found");
    while (1);
  }

  Serial.println("FUSB302 ready");
  Serial.print("Source PDO count: ");
  Serial.println(fusb.getSourcePDOCount());

  // Request 12V at 1.5A (18W) from the source
  if (fusb.requestPDO(2, 1500)) {   // PDO index 2 is 12V on most sources
    Serial.println("Negotiated 12V");
  } else {
    Serial.println("Negotiation failed, falling back to 5V");
  }
}

void loop() {
  // The FUSB302 holds the negotiation; nothing to do.
  // If the source disconnects, the INT pin goes LOW.
  delay(1000);
}

The requestPDO(index, current_mA) call sends the request to the charger. The index refers to the Power Data Object the source advertised; for most USB-C chargers, index 0 is 5V, index 1 is 9V, index 2 is 12V, index 3 is 15V, index 4 is 20V. The exact ordering depends on the charger.

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include "FUSB302.h"

FUSB302 fusb;

void setup() {
  Serial.begin(9600);
  Wire.begin();
  Wire.setClock(400000);

  fusb.begin();

  Serial.print("Source PDO count: ");
  Serial.println(fusb.getSourcePDOCount());

  if (fusb.requestPDO(2, 1500)) {
    Serial.println("12V negotiated");
  }
}

void loop() {}

On the 5V Arduino, the I2C pull-ups on the FUSB302 board are usually 3.3V. That works with the Arduino's 5V I2C because the FUSB302 is 3.3V tolerant on its SDA/SCL pins.

Reading the negotiated voltage

The FUSB302 can also read back the actual voltage the source is providing. Useful for confirming the negotiation worked:

uint16_t voltage_mV = fusb.getVBUSVoltage();
Serial.print("VBUS: ");
Serial.print(voltage_mV / 1000.0);
Serial.println(" V");

The chip measures VBUS through an internal ADC. The reading is accurate to about ±5%, which is enough for "is it 5V or 12V."

Why a trigger board, not just a USB-C connector

A plain USB-C connector passes 5V through. That is fine for a phone charger, but most ESP32 projects need either 3.3V (which the on-board regulator handles) or 12V (for an LED strip, a fan, a motor). A USB-C connector cannot give you 12V; the source provides 5V unless the device asks for more over the CC lines.

The trigger board is the part that asks. Without it, you are stuck with 5V.

The HW-715 vs. the FUSB302

The HW-715 is a fixed-voltage trigger. The voltage is set by solder jumpers on the back; the ESP32 has no control. It is cheaper ($3-5) and simpler (no code, no I2C). Use it when the project always needs the same voltage (e.g. a 12V LED strip).

The FUSB302 is the I2C-controlled trigger. The ESP32 requests the voltage at runtime. It is more expensive ($10 for the Adafruit board) and needs code, but it lets the project change voltages on demand (e.g. "12V when the LED strip is on, 5V otherwise" for power saving).

Power and the ESP32's regulator

The 12V (or 20V) from the trigger is way too high for the ESP32's on-board regulator (which expects 5-12V max). The right pattern:

  1. Trigger outputs 12V to the project's main power rail.
  2. The main power rail feeds the LED strip, fan, or motor.
  3. A separate buck converter drops 12V to 5V for the ESP32's USB or VIN pin.
  4. The ESP32's on-board regulator drops 5V to 3.3V for itself.

For 5V-only projects, the trigger outputs 5V and the buck converter is not needed.

What you learned

  • USB-C PD lets a single charger deliver 5V-20V.
  • The trigger board does the negotiation; the ESP32 can either pre-configure the trigger (HW-715) or control it over I2C (FUSB302).
  • The negotiated voltage is read back through the FUSB302's internal ADC.
  • For 12V/20V projects, you need a buck converter to drop the voltage to 5V for the ESP32.

When something breaks

  • No negotiation, charger stays at 5V. USB-C cable is charge- only. Replace with a data + power cable. The CC lines need to be connected for PD to work.
  • FUSB302 not found on I2C scan. Wrong I2C address (the Adafruit board is 0x22 by default; some clones are 0x23). Run a scanner and update.
  • Negotiated 5V when you wanted 12V. The source does not advertise 12V. Some cheap chargers only do 5V + 9V; some only do 5V + 20V. Check with fusb.getSourcePDOCount() and print each PDO.
  • ESP32 resets when the trigger fires. The 12V rail is sagging under load. Add a 100uF capacitor on the 12V rail, or use a heavier-gauge wire.
  • Negotiation works, but the load does not turn on. VBUS enable pin not driven. Some FUSB302 boards have a separate VBUS load switch that must be enabled by a GPIO. Check the board's schematic.

What to build next

  • The 18650 with TP4056 tutorial is the right pick for battery-powered projects.
  • The relay module tutorial combined with this lets you switch 12V loads using a USB-C brick as the supply.
  • The book ESP32 in Production has a 12V LED strip controller that uses the FUSB302 to negotiate 12V from a 65W USB-C laptop charger.

Chapter 56

ESP32: power with a LiPo battery and TP4056 or MCP73831 charger

esp32 · 25 min

The LiPo (lithium polymer) battery is the right pick for most ESP32 projects that get handled, carried, or sit in a box. It is sealed, soft, and does not have the puncture risk of an 18650 cell. The trade: lower energy density, more expensive per Wh, and the flat-pouch shape does not fit a standard battery holder.

I default to LiPo for wearable projects, sensor boxes that get moved, and anything that might be dropped. I default to 18650 for fixed installs (solar-powered, weatherproof enclosures, anything that sits on a shelf and never gets touched).

The charger is the same idea as the 18650 tutorial: a TP4056 (or MCP73831) handles the CC/CV charge profile and over-discharge protection.

What you need

  • 1S LiPo battery (3.7V nominal, 4.2V fully charged; pick a capacity to match your runtime needs; 1000 mAh is a common pick for ESP32 sensor projects)
  • TP4056 charge controller board (the kind with battery protection built in; about $1) OR an MCP73831 breakout (about $2, smaller)
  • 3.7V to 3.3V LDO regulator (the ME6211 or HT7333 are the standard picks for low quiescent current)
  • 100uF electrolytic capacitor for the LDO output
  • JST-PH 2.0mm connector (most LiPos ship with this; the TP4056 boards also have a JST-PH port)
  • Wires, soldering iron, basic tools

Get a LiPo with a built-in protection circuit (PCM/BMS). The protection circuit cuts off the cell at 2.5V (over-discharge) and 4.3V (over-charge). Bare LiPo cells do not have this; they rely on the charger board to provide it. For most projects, a protected cell + TP4056 is double-protection, which is fine.

Wiring (TP4056)

LiPo +   -- TP4056 B+   (or JST-PH red wire)
LiPo -   -- TP4056 B-   (or JST-PH black wire)
USB 5V   -- TP4056 IN+  (or USB-C connector's VBUS pin)
USB GND  -- TP4056 IN-  (or USB-C connector's GND pin)
TP4056 OUT+ -- LDO IN   (3.7-4.2V from the battery)
TP4056 OUT- -- LDO GND
LDO OUT (3.3V) -- ESP32 3.3V
LDO GND        -- ESP32 GND

Important: do not power the ESP32 from the TP4056's OUT+ pin directly. The LiPo is 4.2V when fully charged, which exceeds the ESP32's 3.3V maximum. The LDO regulator is mandatory.

Wiring (MCP73831)

The MCP73831 is a smaller, single-chip version of the TP4056. The breakout boards are about the size of a SOIC-8 chip. The wiring is the same:

LiPo +   -- MCP73831 BAT
LiPo -   -- MCP73831 GND
USB 5V   -- MCP73831 VCC (5V from USB)
USB GND  -- MCP73831 GND
MCP73831 BAT -- LDO IN

The MCP73831's charge current is set by a single resistor on the PROG pin:

RPROG Charge current
20 kohm 100 mA
10 kohm 200 mA
5 kohm 400 mA
2 kohm 1 A (max)

For a 1000 mAh LiPo, 500 mA charge current is the safe pick. That is a 4 kohm resistor. Most MCP73831 breakouts have a 2 kohm default, which charges at 1A. For larger LiPos, the 1A rate is fine. For small LiPos (under 500 mAh), drop to 5 kohm or 10 kohm.

What the TP4056 actually does

The TP4056 is a linear charger for single-cell LiPo / Li-ion batteries. It charges using the CC/CV profile:

  1. Constant current (CC) at the programmed rate (usually 1A, set by R3 on the module) until the battery reaches 4.2V
  2. Constant voltage (CV) at 4.2V until the charge current drops below 10% of the set rate

This is the standard lithium charging profile. The TP4056 also includes the DW01 protection chip and a dual MOSFET, which provide over-discharge, over-charge, short circuit, and over-current protection. Get the TP4056 with these chips on the board (the "DW01 + 8205A" variant). The bare TP4056 does not have these.

LiPo vs. 18650

Property LiPo 18650
Energy density (Wh/kg) 150-200 200-260
Form factor Flat pouch, soft Hard cylinder
Puncture risk Low (sealed) High (fire if punctured)
Best for Wearables, portable Fixed, high-capacity
Cost per Wh Higher Lower
Voltage 3.7V nominal 3.7V nominal
Capacity range 100-5000 mAh 1000-3500 mAh
Common chargers TP4056, MCP73831 TP4056, same

The energy density gap matters less than it looks. A 1000 mAh LiPo weighs about 25g; a 3000 mAh 18650 weighs about 50g. The 18650 has 3x the capacity in 2x the weight, but the LiPo fits in places the 18650 cannot (a flat enclosure, a wristband, a small box).

Reading the battery voltage

The ESP32 can read the LiPo voltage through a divider:

const int BATT_PIN = 34;
const float R1 = 100000.0;   // 100k
const float R2 = 100000.0;   // 100k (1:1 divider)

float readBatteryVoltage() {
  int raw = analogRead(BATT_PIN);
  float adcVoltage = raw * 3.3 / 4095.0;
  return adcVoltage * (R1 + R2) / R2;
}

Wire the divider between the TP4056 OUT+ pin and ESP32 GND, with the middle node on GPIO 34. The LiPo is 4.2V max; the divider halves that to 2.1V, which the ESP32's 3.3V ADC can read.

For an accurate state-of-charge percentage, the LiPo discharge curve is non-linear. Approximate table:

Voltage State of charge
4.20V 100%
3.90V 75%
3.80V 50%
3.70V 25%
3.60V 10%
3.50V 0% (protection cuts off near here)

The DW01 protection circuit cuts off the cell at about 2.5V. Do not run a LiPo that low; it damages the cell and reduces its capacity permanently.

Battery life math

A 1000 mAh LiPo at 3.7V nominal is 3.7 Wh. The ESP32 draws about 30 mA active (with Wi-Fi) or 0.1 mA in deep sleep. For a sensor that wakes every minute:

Average current = 0.99 * 0.1 mA + 0.01 * 30 mA = 0.4 mA
Battery life = 1000 mAh / 0.4 mA = 2500 hours = 104 days

For a sensor that wakes every 10 seconds (active current dominates):

Average current = ~3 mA
Battery life = 1000 / 3 = 333 hours = 14 days

The above numbers ignore the TP4056's quiescent current (~50 uA) and the LDO's quiescent current (~10 uA for the ME6211). For short projects, those matter; for a 100-day project, they are rounding errors.

LiPo safety

LiPos are safer than 18650s in most ways, but they have their own rules:

  1. Do not puncture the cell. A punctured LiPo releases electrolyte and can vent flame. The pouch is soft; be careful with sharp tools near it.
  2. Do not short the terminals. A shorted LiPo can deliver hundreds of amps and overheat. Most LiPos have a PCM that cuts off on over-current, but the protection is not instant.
  3. Charge at the right rate. Charging faster than 1C (e.g. a 1000 mAh cell at >1A) damages the cell. The TP4056's default 1A is fine for cells 1000 mAh and up; smaller cells need a lower charge rate.
  4. Do not charge below 0°C. Lithium plating damages the cell. If the project is outdoors, do not charge when the temperature is below freezing.
  5. Use a fireproof bag for storage. LiPo charging bags (the flame-retardant pouches) cost a few dollars. Use them for any LiPo you are not actively using.

When the protection circuit trips

The DW01 chip on the TP4056 cuts off the load when the cell drops below 2.5V. The fix: plug in USB. The TP4056 will charge the cell back up to the protection threshold (usually 3.0V) before re-enabling the output.

If the cell is below 2.5V for more than a few days, it is permanently damaged. The voltage may come back, but the capacity will be reduced. Replace the cell.

What you learned

  • LiPo is the right pick for projects that get handled.
  • TP4056 or MCP73831 handles the charge profile and protection.
  • A 1:1 voltage divider on GPIO 34 reads the battery voltage.
  • The 3.7V to 3.3V LDO is mandatory; do not power the ESP32 from the raw battery.

When something breaks

  • Battery reads 0V or the ESP32 does not power up. Protection circuit tripped. Plug in USB; the TP4056 will charge back up.
  • ESP32 resets when Wi-Fi connects. Bulk capacitor too small or missing. Add 100uF across the LDO output.
  • TP4056 gets very hot during charging. Charging current is too high for the LiPo size. Replace the R3 resistor with a larger value (e.g. 10 kohm for 130 mA).
  • Battery percentage is wrong. The LiPo discharge curve is non-linear. Use a lookup table, not a linear formula.
  • LiPo is puffy. Stop using it. A puffy LiPo has internal gas buildup and is a fire risk. Dispose of it at a battery recycling center.

What to build next

  • The 18650 with TP4056 tutorial is the right pick for fixed installs.
  • The deep sleep tutorial shows the 99%-asleep math for long battery life.
  • The book ESP32 in Production has the full LiPo certification (UN38.3) walkthrough for shipping products with lithium batteries.

Chapter 57

ESP32: HTTP server in depth, routing, JSON APIs, and async handlers

esp32 · 45 min

The first ESP32 HTTP server I wrote was a single page that returned "Hello, world!" and a list of GPIO states. It worked. Then I tried to add a second page. Then a third. Then someone asked me to add a POST endpoint for setting the GPIO state from a browser. Then I needed CORS because the dashboard lived on a different host. By the time I had eight endpoints, four HTTP methods, JSON parsing on the way in, JSON serialization on the way out, and proper error handling, the original "Hello, world!" was buried under a pile of if (uri == "/foo") branches that I was afraid to touch.

This tutorial is what I wish I had read first. It covers the routing pattern (on("/path", handler)), the HTTP method dispatch, JSON request and response handling, the async handler library that lets you serve many concurrent requests without blocking, the CORS pattern for browser clients, and the "request was too big" gotcha that bites when someone POSTs a 100 KB payload to a sensor endpoint.

WebServer vs WebServerSecure (the first decision)

The Arduino IDE ships two libraries for HTTP serving:

  • WebServer (HTTP, plain text). Default port 80. Used for LAN-only servers where TLS is overkill.
  • WebServerSecure (HTTPS). Default port 443. Used when the client is on a different network and you want the bytes encrypted on the wire. Requires a WiFiClientSecure and the same setCACert() pattern from the TLS tutorial.

For a home dashboard on the same WiFi, plain HTTP is fine. The traffic is on a network you control. Adding HTTPS adds the cert management burden for no real security gain (the attacker is already on your WiFi if they can sniff the traffic).

For anything exposed to the internet (port-forwarded, on a public WiFi, behind a reverse proxy that terminates TLS), use HTTPS. The TLS tutorial covers the CA bundle part.

Route patterns (the on("/path", handler) API)

WebServer (and WebServerSecure) expose a clean routing API:

#include <WiFi.h>
#include <WebServer.h>

WebServer server(80);

void handleRoot() {
  server.send(200, "text/plain", "Hello, world!");
}

void handleStatus() {
  server.send(200, "text/plain", "OK");
}

void handleNotFound() {
  server.send(404, "text/plain", "Not found");
}

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  server.on("/", handleRoot);
  server.on("/status", handleStatus);
  server.onNotFound(handleNotFound);

  server.begin();
  Serial.print("Listening on ");
  Serial.println(WiFi.localIP());
}

void loop() {
  server.handleClient();
}

Three pieces:

  • server.on(path, handler): register a handler for a specific path. The handler is called when the request matches.
  • server.onNotFound(handler): register the fallback handler. Called for any path that did not match a registered route.
  • server.handleClient(): the per-loop call that processes incoming requests. Must be called regularly or the server stalls.

Routes are exact-match. /status does not match /status/details. If you want tree-style routing, register each path explicitly or check server.uri() inside the handler.

HTTP method handling (GET vs POST)

The default server.on(path, handler) matches any HTTP method. To dispatch by method:

server.on("/sensor", HTTP_GET, []() {
  server.send(200, "application/json", "{\"temp\":22.5}");
});

server.on("/sensor", HTTP_POST, []() {
  // Read the POST body
  if (!server.hasArg("plain")) {
    server.send(400, "application/json", "{\"error\":\"no body\"}");
    return;
  }
  String body = server.arg("plain");
  // Parse body, update sensor...
  server.send(200, "application/json", "{\"ok\":true}");
});

The HTTP method constants are HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_DELETE, HTTP_PATCH, HTTP_HEAD, HTTP_OPTIONS. Registering the same path with different methods gives you proper REST-style routing.

HTTP_OPTIONS is special: it is what browsers send as a CORS preflight request. If your API is called from a browser on a different origin, you need to handle OPTIONS. More on that below.

Reading query parameters

Query strings (the ?foo=bar&baz=qux part of the URL) come in as named arguments:

server.on("/set", HTTP_GET, []() {
  if (!server.hasArg("value")) {
    server.send(400, "text/plain", "missing value");
    return;
  }
  String value = server.arg("value");
  // Use value...
  server.send(200, "text/plain", "set to " + value);
});

Call the URL as /set?value=42. The library parses the query string and exposes each parameter as a named argument.

Parsing JSON request bodies

For POST requests, the body comes in as the "plain" argument:

#include <ArduinoJson.h>

server.on("/config", HTTP_POST, []() {
  if (!server.hasArg("plain")) {
    server.send(400, "application/json", "{\"error\":\"no body\"}");
    return;
  }

  StaticJsonDocument<512> doc;
  DeserializationError err = deserializeJson(doc, server.arg("plain"));
  if (err) {
    server.send(400, "application/json", "{\"error\":\"bad json\"}");
    return;
  }

  const char* ssid = doc["ssid"] | "default-ssid";
  const char* pass = doc["pass"] | "default-pass";

  // Apply config...
  WiFi.begin(ssid, pass);

  server.send(200, "application/json", "{\"ok\":true}");
});

ArduinoJson is the standard. Install via Sketch >> Include Library >> Manage Libraries >> search ArduinoJson. The StaticJsonDocument<512> allocates 512 bytes on the stack. For larger payloads use DynamicJsonDocument or bump the static size.

Serving JSON responses with proper Content-Type

Always set Content-Type: application/json on JSON responses. The browser and curl both use this header to decide how to render the response:

StaticJsonDocument<256> doc;
doc["temperature"] = 22.5;
doc["humidity"]    = 55.0;
doc["timestamp"]   = millis();

String response;
serializeJson(doc, response);
server.send(200, "application/json", response);

serializeJson writes the JSON to a String. For small payloads this is fine. For large ones, use serializeJson(doc, server.client()) to write directly to the socket and skip the intermediate string allocation.

404 and 500 handling

Two error paths you have to wire up explicitly:

  • 404 Not Found for paths that did not match any route. Register with server.onNotFound().
  • 500 Internal Server Error for handlers that throw or hit an unexpected state. Wrap handler bodies in try/catch where possible, or set up a generic error responder:
server.onNotFound([]() {
  StaticJsonDocument<64> doc;
  doc["error"] = "not found";
  doc["path"]  = server.uri();
  String body;
  serializeJson(doc, body);
  server.send(404, "application/json", body);
});

For the 500 case, put server.send(500, "application/json", ...) in any catch block or after any if (!ok) check that you cannot recover from.

The async handler pattern (ESPAsyncWebServer)

The synchronous WebServer blocks the request thread while a handler runs. For most ESP32 projects that is fine: each handler takes milliseconds, and the WiFi stack has its own thread. But when a handler does anything slow (reads a sensor over I2C, waits for an HTTP call to an upstream service, runs an OTA check), the entire server stalls for that duration. Other clients get timeouts.

ESPAsyncWebServer is the fix. Handlers return immediately and the server sends the response when it is ready:

#include <ESPAsyncWebServer.h>

AsyncWebServer server(80);

server.on("/slow", HTTP_GET, [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
  // Start the slow operation in the background
  // (e.g. trigger an I2C read, fire an HTTP request, etc.)
  request->send(200, "application/json", "{\"started\":true}");
});

Install via the Arduino Library Manager: search ESPAsync WebServer by me-no-dev.

The async version handles concurrent requests cleanly. If you have more than 5-10 simultaneous clients, or any handler that takes more than a few hundred milliseconds, the async version is worth the swap.

The catch: ESPAsyncWebServer does not handle chunked responses or websockets as cleanly as the sync version, and the request body API is different. For a pure REST API with small payloads, the swap is easy. For anything fancier, expect to read the library source.

CORS for browser-based clients

If your dashboard is served from dashboard.example.com and the ESP32 API is on 192.168.1.42, the browser blocks the request unless the ESP32 sends the right CORS headers. The minimum:

server.on("/sensor", HTTP_GET, [](AsyncWebServerRequest *request) {
  AsyncWebServerResponse *response = request->beginResponse(
    200, "application/json", "{\"temp\":22.5}");
  response->addHeader("Access-Control-Allow-Origin", "*");
  // For credentialed requests, replace * with the specific origin
  // and add: Access-Control-Allow-Credentials: true
  request->send(response);
});

For POST and other methods, you also need to handle the preflight:

server.on("/sensor", HTTP_OPTIONS, [](AsyncWebServerRequest *request) {
  AsyncWebServerResponse *response = request->beginResponse(204);
  response->addHeader("Access-Control-Allow-Origin", "*");
  response->addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  response->addHeader("Access-Control-Allow-Headers", "Content-Type");
  request->send(response);
});

The * for Access-Control-Allow-Origin is fine for development. For production, lock it down to the specific origin(s) you serve the dashboard from.

The "request was too big" gotcha

WebServer has a default max request size of about 1 KB (or whatever _maxHeadersLength is set to in the library). If a client POSTs a 10 KB JSON body, the body gets truncated or the request is rejected outright.

The fix: bump the limit. In the sync WebServer:

server.setMaxPayloadLength(16384);   // 16 KB

In the async ESPAsyncWebServer:

server.setMaxPayloadLength(16384);

Pick a number that is just larger than your largest expected payload. Going too big (e.g. 1 MB) lets a malicious client tie up RAM with a single request.

For multipart uploads (file uploads), the library has separate UploadHandler callbacks. Use those instead of trying to handle file uploads through the body parser.

When to use HTTP server vs MQTT

Two main choices for serving data from the ESP32:

  • HTTP server: the client pulls data when it wants it. Good for "fetch the current sensor reading," "send me the device status," "update this config." Request-response pattern, easy to debug with curl, no broker needed.
  • MQTT publisher: the ESP32 pushes data when it changes. Good for regular telemetry, multiple subscribers, fanout to dashboards and automation engines.

Most projects use both. The ESP32 publishes telemetry over MQTT; the dashboard fetches config or sends commands via HTTP. The HTTP server tutorial (esp32-http-server-in-depth) is the second half; the MQTT tutorial (esp32-mqtt-publish-subscribe) is the first.

When something breaks

  • "Handler not called." Check the route registration. Check that server.handleClient() is in loop(). Check the method matches (GET vs POST).
  • "CORS error in browser console." You did not send the Access-Control-Allow-Origin header, or you did not handle the OPTIONS preflight.
  • "Request times out." A handler is blocking. Either split the handler into a quick "started" response + background work, or move to ESPAsyncWebServer.
  • "Body is empty when I read it." The client did not send a Content-Length header, or the body was too big and got truncated. Check server.contentLength() and server.hasArg("plain").
  • "Server stops responding after a few hours." Memory leak in a handler, or the async server has a stuck request. Watch ESP.getFreeHeap() over time. If it is dropping, you are leaking.

What to build next

  • A real REST API: /sensor (GET returns JSON), /sensor (POST updates the reading), /config (GET/POST for WiFi credentials), /reset (POST triggers a restart).
  • A websocket endpoint that pushes sensor readings every second to connected clients. ESPAsyncWebServer has built-in websocket handlers.
  • Authentication via bearer tokens in the Authorization header. Without it, anyone on your WiFi can call your API.
  • An MQTT-based equivalent (esp32-mqtt-publish-subscribe) and a comparison of which you reach for first.

Chapter 58

ESP32: JTAG debugging with OpenOCD and a logic probe

esp32 · 45 min

I had a firmware crash that took me three weeks to find. The backtrace was useless because the crash happened inside an ISR. Serial.print inside the ISR was not an option (the UART itself was what was crashing). Disabling interrupts made the crash go away, which told me nothing useful. Logic analyzer showed the GPIO state at the time of the crash but not why.

JTAG solved it in an afternoon. I set a hardware breakpoint at the start of the ISR, single-stepped through it, and watched the registers. A buffer pointer had wrapped around and the write that crashed was to address 0x00000000 because of an unsigned overflow. Three weeks of guessing, one afternoon of stepping.

JTAG is overkill for most debugging. When you need it, you need it badly. This tutorial is the setup I use.

What JTAG is

JTAG is a hardware debug interface built into most microcontrollers. It lets an external probe:

  • Halt the CPU at any instruction
  • Set breakpoints (hardware, not software)
  • Read and write CPU registers
  • Read and write memory (flash and RAM)
  • Single-step through code one instruction at a time
  • Resume execution

The CPU runs normally until the probe halts it. No code changes, no software hooks, no instrumented binaries. The probe talks to a dedicated hardware debug block on the chip over a 4-wire (or 2- wire for ESP32-S2/C3/S3) interface.

The catch: you need a hardware probe, a piece of software (OpenOCD) that knows how to talk to the probe and the chip, and a debugger (GDB) that talks to OpenOCD. The setup is fiddly. The reward is that you can debug anything that runs on the chip, with full visibility into the state at the moment of a crash.

Hardware probes

ESP-Prog ($15 from Espressif's store):

The official Espressif probe. Built specifically for ESP32 JTAG. USB-C, on-board 3.3V regulator so you can power a small board from it, JTAG and serial over the same USB cable. The right pick for ESP32 specifically.

J-Link EDU Mini ($20 from Segger):

The J-Link is the gold standard for ARM debugging. The EDU Mini is the cheap educational version (no commercial use allowed per the license). Works with every ARM chip, including the ESP32-S2/S3/C3 family. Faster than ESP-Prog.

J-Link BASE ($400): the full version. Faster, more features, no usage restrictions. Not worth it for hobby work.

CMSIS-DAP probes ($10-30):

The CMSIS-DAP standard works with any ARM chip. Cheap clones on AliExpress. The interface is slower than J-Link but the price is right. Pick one if you are doing ESP32-S3/C3 work and want one probe for all your ARM boards.

FTDI FT2232H ($20 chip, $40 board):

Some FT2232H-based boards (like the CJMCU-2232HL) support JTAG. Older ESP32 boards (the original DevKit v1) used this pattern. Works fine but requires more wiring than the dedicated probes.

For most people starting JTAG on ESP32, the ESP-Prog is the right pick. It is designed for this exact chip, costs $15, and the JTAG

  • serial on one cable is genuinely convenient.

Wiring the JTAG pins

The classic ESP32 JTAG pins are:

ESP32 pin JTAG signal
GPIO14 TMS
GPIO12 TDI
GPIO13 TCK
GPIO15 TDO
GND GND
3.3V VTREF

These are the default JTAG pins for the ESP32-WROOM-32 and most original ESP32 modules. The ESP32-S2, S3, and C3 use different pins (and a 2-wire "cJTAG" interface instead of the 4-wire JTAG). Always check the datasheet for the specific chip you have.

You also need to make sure these pins are not being used by your firmware. If your code initializes GPIO12 as a GPIO, the JTAG won't work. The fix is either to not touch those pins in your firmware, or to add espefuse.py set_flash_voltage 3.3 style configuration that makes them JTAG-only at boot.

OpenOCD setup

OpenOCD is the open-source piece that talks to the probe on one side and GDB on the other. You need:

  1. The OpenOCD binary (the Espressif fork, not the upstream one, because the ESP32 support is in the Espressif fork).
  2. A config file that describes your probe + chip.

Install OpenOCD. The Arduino ESP32 board package includes a copy on most platforms. The path is something like:

~/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20240318/openocd-esp32/bin/openocd

Add that to your PATH. Then run:

openocd -f interface/esp-prog.cfg -f target/esp32.cfg

For a J-Link, replace interface/esp-prog.cfg with interface/jlink.cfg. For a CMSIS-DAP probe, use interface/cmsis-dap.cfg. The target file is the same.

OpenOCD starts a GDB server on port 3333 and a telnet interface on port 4444. Leave it running in a terminal. You will connect to it from a separate GDB session.

GDB integration with the Arduino IDE

The Arduino IDE does not have native GDB integration. You have two options:

  1. Plain GDB in a terminal. Run xtensa-esp32-elf-gdb firmware.elf (the ELF file is what the Arduino IDE produces in the build output directory). Connect with target remote localhost:3333. Set breakpoints, step, inspect.

  2. VS Code + PlatformIO. PlatformIO has built-in GDB integration. Add debug_tool = esp-prog (or jlink, cmsis-dap) to platformio.ini, click the debug button, and VS Code manages the OpenOCD + GDB session. This is what I use daily.

  3. CLion + PlatformIO. Same as VS Code but with the JetBrains IDE. Better code navigation, worse startup time.

For this tutorial I will use plain GDB because it is the lowest common denominator. The same commands work from any IDE.

Setting breakpoints

Find the ELF file the Arduino IDE produced. It is in the build output directory; in recent Arduino IDEs, the path is something like ~/Arduino/build/<sketch_name>/<sketch_name>.ino.elf.

xtensa-esp32-elf-gdb sketch.ino.elf

(gdb) target remote localhost:3333
(gdb) monitor reset halt

# Set a breakpoint at the start of loop()
(gdb) break loop

# Or at a specific line in a specific file
(gdb) break src/main.cpp:42

# Or at the address of an ISR (useful for the original bug I mentioned)
(gdb) break gpio_isr_handler

# Start the program
(gdb) continue

When the breakpoint hits, GDB stops. You can:

  • print variable_name to read a variable's value
  • info locals to see all locals in the current frame
  • info registers to see all CPU registers
  • backtrace to see the call stack
  • step to execute one source line, stepping into function calls
  • next to execute one source line, stepping over function calls
  • continue to resume until the next breakpoint

The combination of step and next is the heart of debugging. step goes into the function you are calling. next runs the function without entering it. Use next to fly past library code and step to dig into your own.

Inspecting memory and registers

The most useful GDB commands for embedded debugging:

  • info registers: all CPU registers. pc is the program counter, sp is the stack pointer, a0-a15 are the general purpose registers.
  • x/16x 0x3FF00000: dump 16 words of memory at address 0x3FF00000. Use this to inspect data structures, peripheral registers, or raw memory.
  • x/16x $sp: dump 16 words starting at the stack pointer. This is the raw stack contents, useful when you suspect a stack overflow.
  • set var x = 5: change the value of variable x. You can patch state at runtime.
  • set {int}0x3FF00000 = 42: write to a specific memory address. Useful for poking peripheral registers directly.

For ESP32 specifically, the address 0x3FF00000 is the start of the data RAM region, 0x3F400000 is the start of the peripheral register region, and 0x400D0000 is the start of the flash mapping. The exact ranges are in the ESP32 TRM (Technical Reference Manual), chapter 1.

The case where JTAG is necessary vs print debugging

Print debugging wins when:

  • The bug is a logic error, not a crash
  • The variables you need to inspect are easy to print
  • The timing is not tight (printing inside the ISR is the bug)
  • You can reproduce the bug consistently

JTAG wins when:

  • The firmware crashes and you need the state at the moment of the crash
  • The bug is timing-related and printing changes the timing
  • The bug is in an ISR or a callback that runs in interrupt context
  • You need to inspect hardware registers, peripheral state, or DMA buffers
  • You want to set a hardware breakpoint at an exact instruction and catch a rare event (e.g. "only fires when the WiFi reconnects after a 30-second timeout")

A rule of thumb: try print debugging for 30 minutes. If you have not made progress, set up JTAG. The setup takes longer than the first debugging session, but every session after that is faster.

A real workflow (the crash from the introduction)

To find the buffer-overflow bug from my introduction:

  1. Connect ESP-Prog. Wire TMS/TDI/TCK/TDO + GND.
  2. Start OpenOCD. Connect GDB. Reset the target.
  3. break gpio_isr_handler to break at the start of the ISR.
  4. continue. Toggle the input pin to trigger the ISR.
  5. GDB stops. info registers. Look at a2 (the second argument register, which holds the buffer pointer in the Xtensa calling convention).
  6. print/x $a2 to see the address in hex. Notice it is 0x00000000. That is the crash.
  7. backtrace. See who called the ISR with that pointer.
  8. The caller is a queue handler. Step back through it. Find the line where the pointer was supposed to be set. Find the arithmetic bug that wrapped it.

What would have taken days with print debugging was 20 minutes with JTAG. Setup time was 2 hours (one time, the first time).

Common gotchas

  • The ESP32 is using JTAG pins as GPIO. Make sure your firmware does not initialize GPIO12-15.
  • OpenOCD cannot connect. Check the wiring. Check that the ESP32 is powered (some probes do not supply enough current through VTREF). Try a different USB cable.
  • GDB says "remote replied unexpectedly". You forgot to start OpenOCD, or OpenOCD is on a different machine than GDB. They have to be on the same machine or the network has to allow port 3333.
  • Breakpoints do not trigger. The code might have been optimized out. Build with -Og (debug optimization) instead of -Os (size optimization) so the compiler keeps the lines you set breakpoints on.
  • GDB shows the wrong source line. The source file was edited after the ELF was built. Rebuild the ELF and reconnect.

What to build next

  • A test sketch with a deliberate bug (null pointer dereference, buffer overflow, infinite loop). Set a breakpoint, find it.
  • The same bug without JTAG, using only Serial.print. Notice how much longer it takes.
  • An ESP32-S3 project that uses the built-in USB-JTAG (no external probe needed). The S2/S3/C3 have USB-JTAG on the native USB port; the wiring is just a USB cable.
  • VS Code + PlatformIO if you are not already using it. The IDE integration makes JTAG debugging a one-click operation.

Chapter 59

ESP32: debug with a logic analyzer and protocol decoders

esp32 · 30 min

I spent two days convinced a BME280 sensor was broken because my ESP32 was reading zero humidity. The I2C scanner said the device was there at 0x76. The library was the right one. The wiring matched the datasheet. I was about to order a replacement sensor when I borrowed a logic analyzer, clipped three wires onto SDA, SCL, and GND, and watched the actual bus traffic.

The ESP32 was sending the right address. The sensor was ACKing every byte. Then on the third byte (the humidity config register), the ESP32 was sending 0x00 instead of 0x01. Typo in my code. The Serial.print of the value being written showed the right number; the byte actually going out on the wire was wrong because of a bit-shift bug two lines earlier. Serial.print told me what I thought was true. The bus told me what was actually happening.

This tutorial is the workflow I use now whenever something is not working and Serial.print is not enough.

What a logic analyzer does

A logic analyzer records digital signals (high/low) on multiple channels at the same time. You connect probes to the pins you care about (e.g. SDA and SCL for I2C, MOSI/MISO/SCK/SS for SPI, TX/RX for UART). The analyzer samples those pins at a fixed rate and stores the transitions. You then look at the recorded waveform on your computer.

The difference between a logic analyzer and an oscilloscope:

  • Logic analyzer: many channels (8 to 32 is common), only digital (high/low, not analog voltage), long capture time (millions of samples), designed for protocols like I2C/SPI/UART.
  • Oscilloscope: fewer channels (2 to 4 typical), analog voltage, short capture time, designed for waveforms and timing.

If you want to know "is the right byte going out on the I2C bus," the logic analyzer is the tool. If you want to know "what does the rising edge of this signal look like," the oscilloscope is the tool. They overlap in the middle but the tool choice is usually obvious.

The cheap options (and the not-cheap one)

$10 Saleae clone (the pick for getting started):

These are USB dongles with 8 channels, 24 MHz sample rate, that work with the Saleae Logic software (the clone vendors reverse- engineered the protocol). You can find them on AliExpress by searching "Saleae Logic Analyzer" or "CY7C68013A logic analyzer." The clone of the original Saleae Logic (8-channel, 24 MHz) is about $8-12.

$400 Saleae Logic Pro 8:

The real thing. 8 channels, 500 MHz sample rate, well-built, comes with software that just works. Worth it if you are doing this weekly. Not worth it if you are doing this once.

$30-50 Kingst LA2016 / LA1016:

Chinese brand, decent quality, official Sigrok support (no clone hassle). About $30 for the 16-channel 100 MHz version. This is what I use day-to-day.

$100-150 Rigol logic probe modules:

Add-on for some Rigol oscilloscopes. If you already have a Rigol scope, this is a no-brainer.

For the rest of this tutorial I am going to assume the $10 Saleae clone because that is what most people have and what I started with. The Sigrok workflow works the same on all of them.

Sigrok and PulseView setup

Sigrok is the open-source protocol decoding suite. PulseView is its GUI. Together they handle just about every logic analyzer on the market, including the $10 clones.

Linux:

sudo apt install sigrok pulseview

macOS:

brew install sigrok pulseview

Windows:

Download the PulseView installer from https://sigrok.org/wiki/Downloads. There is a single installer that includes the CLI tools and the GUI.

For the Saleae clone specifically:

The clone needs a kernel driver on Windows (the zadig tool handles this) and a libusb setup on Linux. Sigrok's wiki has a page specifically for the "Saleae Logic clone" that walks through the driver install. Spend the 20 minutes, save yourself years of "why does my analyzer not show up" frustration.

When you plug the analyzer in, PulseView should auto-detect it. If it does not, check Device >> Connect to device and pick the right driver. For the $10 clones, the driver is fx2lafw.

Basic digital capture (the no-protocol workflow)

The simplest thing you can do: probe one GPIO, capture the waveform, see if the transitions match what you expect.

  1. Connect the analyzer's GND clip to your board's GND.
  2. Connect channel 0 to the GPIO you want to watch.
  3. In PulseView, click the green Run button.
  4. Toggle the GPIO from your firmware.
  5. PulseView shows a square wave (or whatever your firmware is actually producing).

If you see the waveform you expected: the pin is wired correctly and the firmware is driving it. If you see nothing: the pin is not toggling, or your probe is on the wrong pin, or the analyzer's ground clip is not connected (this last one is the silent killer; without a ground reference, the analyzer floats and shows random noise).

The default sample rate is 1 MHz, which is enough for almost everything below SPI at 1 MHz. For faster signals, click the sample rate dropdown and bump to 10 MHz or 100 MHz.

The I2C / SPI / UART protocol decoders

This is where the magic happens. PulseView ships with decoders for every protocol you care about. Instead of staring at a square wave trying to mentally parse the bits, you get a human-readable timeline:

[IDX] SDA SCL  | Interpretation
[001] --- ___  | START
[002] 110 0011 | Address: 0x76 (W)
[003] 0-- 0010 | ACK
[004] 1110 0011| Data: 0xE3
[005] 0-- 0010 | ACK
...

To use a decoder:

  1. Capture your signal (channels 0 and 1 for I2C, four channels for SPI, two for UART).
  2. Click the + icon next to "Decoders" in the right sidebar.
  3. Pick the protocol (e.g. i2c).
  4. Tell PulseView which channel is which (SDA, SCL for I2C; MOSI, MISO, CLK, CS for SPI; RX, TX for UART).
  5. The decoded annotations appear above the raw waveform.

For SPI, set the clock polarity and phase (CPOL/CPHA) to match your device. For UART, set the baud rate (the decoder can auto-detect from a known string, but explicit is faster). For I2C, nothing extra; the protocol is well-defined.

A real example (the BME280 debug)

Back to the original bug. I had:

  • Channel 0: SDA
  • Channel 1: SCL
  • Sample rate: 1 MHz
  • Decoder: I2C, SDA = D0, SCL = D1

The capture showed:

START
Addr 0x76 (W) ACK
Data 0x74 ACK      <- register pointer: 0x74 (humidity ctrl)
Data 0x00 ACK      <- value: should be 0x01, was 0x00
STOP
START
Addr 0x76 (R) ACK
Data 0x00 NACK     <- readback confirms: sensor got 0x00
STOP

The two-byte write said "set register 0x74 to value 0x00." The sensor obediently did that. The Serial.print in my firmware was the value I intended to send (0x01), but a << 1 left over from an earlier draft of the code shifted the bit out before it hit the wire. Three minutes with the logic analyzer, two minutes to fix the code.

When to use what (the picking chart)

  • Serial.print: 80% of debugging. Use it for "what value did this variable have," "did this code path execute," "what error code came back from this call." Cheap, fast, no extra hardware.
  • Logic analyzer: when Serial.print is not enough. Protocol traffic, pin toggles you cannot easily print (because it is hardware-driven), timing of interrupt handlers, signal integrity on digital lines.
  • Oscilloscope: when you need to see the analog shape of a signal. Power supply noise, PWM waveform edges, analog sensor outputs, signal integrity on long wires.

The order I reach for tools: Serial.print, then logic analyzer, then oscilloscope. Each is more powerful but slower to set up than the last.

GPIO timing analysis

One thing the logic analyzer does that nothing else does well: measure timing between events across multiple pins. "How long after CS goes low does the SPI clock start?" "Is the interrupt pin asserted within 10 us of the I2C transaction ending?"

PulseView has a "Show cursors" tool. Drag the cursor onto one event (e.g. the falling edge of SCL), drag the second cursor onto another event (e.g. the next rising edge of SDA). The delta appears in the bottom toolbar in microseconds. You can also right- click on a transition and set "mark" to align multiple captures side by side.

Common gotchas

  • Sample rate too low. If your signal is 1 MHz and your sample rate is 1 MHz, you get one sample per cycle. The waveform looks blocky and the decoder might mis-read edges. Sample rate rule of thumb: at least 10x the fastest signal frequency. For a 1 MHz SPI bus, sample at 10 MHz or higher.

  • Not enough channels. Eight channels is the standard. If you are debugging a 4-wire SPI plus a UART plus two GPIO events, you need 7 channels. Plan ahead.

  • Ground clip not connected. The silent killer. The analyzer floats, shows random noise, and you chase ghosts for an hour. Always clip ground first.

  • Long wires picking up noise. The clip leads that come with cheap analyzers are unshielded and pick up everything. For high- speed signals, solder a short pigtail directly to the test point. For everything else, the clips are fine.

  • Decoder config mismatch. SPI decoders need the right CPOL/CPHA. UART decoders need the right baud rate. If the decoded output is gibberish, the config is wrong before the signal is wrong.

  • Trigger not set. By default PulseView captures from "now." If your event is intermittent, you might capture the wrong window. Use a trigger (rising edge on CS, falling edge on INT, etc.) to start the capture at the right moment.

What to build next

  • A test sketch that deliberately mis-drives an I2C device (wrong register address, wrong byte count) so you can practice reading the decoder output.
  • An SPI flash capture with the spi decoder set to mode 0; verify the read command sequence matches the datasheet.
  • A UART capture at 115200 baud with the uart decoder, compare decoded bytes to what Serial.print shows. Useful sanity check.
  • The esp32-oscilloscope-basics tutorial if you do not have a scope yet. The scope and the logic analyzer are the two bench tools you actually need.

Chapter 60

ESP32: NVS (Non-Volatile Storage), the right way to persist settings

esp32 · 25 min

The first thing any real IoT project needs to save is the Wi-Fi password. Then it is the MQTT topic. Then the calibration constant for the sensor. Then the device name. Then the user changes one of them, walks away, power-cycles the board, and asks why their change is gone.

If you store settings in a regular variable, every power cycle wipes them. If you store them in RTC memory, every power cycle wipes them. You need flash-backed storage. On the ESP32, that storage is called NVS (Non-Volatile Storage), and the Arduino library for it is Preferences.

This tutorial covers the open / put / get / close pattern, the namespace organization, the blob storage for larger values, wear-leveling, and the data-type gotcha that bites when you mistake a 32-bit int for a 64-bit one.

What NVS is

NVS is a key-value store that lives in a dedicated partition of the ESP32's flash. It survives power cycles, deep sleep, and firmware updates. The Arduino wrapper (Preferences.h) gives you a clean API on top of the ESP-IDF NVS layer.

Properties:

  • Key-value: each entry has a string name and a typed value (int, float, blob, string).
  • Namespaced: keys are grouped into namespaces (e.g. "wifi", "mqtt", "calibration").
  • Wear-leveled: the underlying flash management rotates writes across sectors, so no single flash sector wears out from repeated writes.
  • Slow per-write, fast per-read: each write is a flash erase-and-program cycle, taking ~50 ms. Reads are instant.
  • Typed: each key has a fixed type (int, float, blob). You cannot mix types under the same name.

Default partition size is about 20 KB. Plenty for settings, not enough for logs.

The open / put / get / close pattern

The basic API:

#include <Preferences.h>

Preferences prefs;

void setup() {
  Serial.begin(115200);

  prefs.begin("my-app", false);   // namespace, read-only = false

  // Put values
  prefs.putInt("boot-count", 0);
  prefs.putFloat("calibration", 1.023);
  prefs.putString("device-name", "esp32-01");

  // Get values (with default)
  int bootCount = prefs.getInt("boot-count", 0);
  float cal     = prefs.getFloat("calibration", 1.0);
  String name   = prefs.getString("device-name", "esp32");

  Serial.printf("boot=%d cal=%.3f name=%s\n", bootCount, cal, name.c_str());

  prefs.end();
}

void loop() {}

Three rules:

  1. Call begin() before any put* / get* call. The second argument is read-only mode; set it to true if you only need to read.
  2. Call end() when done. It flushes any pending writes and releases the lock. Forgetting to call end() is the most common NVS bug; the data appears to not save.
  3. Every get* takes a default value. If the key does not exist (first boot, never written, wiped by partition format), the default is returned. This is the right way to handle "no value yet."

The types and their gotchas

Preferences supports four value types:

Type put method get method Size limits
Integer putInt(key, value) getInt(key, default) 32-bit signed (-2^31 to 2^31-1)
Float putFloat(key, value) getFloat(key, default) 32-bit IEEE 754
String putString(key, value) getString(key, default) Up to ~4 KB per key
Blob putBytes(key, data, len) getBytes(key, buf, len) Up to ~4 KB per key, 508 KB total

The gotcha: getInt() returns a 32-bit int. If you stored a uint64_t (e.g. a millisecond timestamp), you cannot read it back as an int. Either store it as two ints (high and low words) or use the blob storage.

Same for putFloat / getFloat: they are 32-bit. If your value is a double, the extra precision is silently truncated on write. For sensor readings this is fine. For money or scientific calculations where the difference matters, scale to integer or use blob.

Namespaces for organization

Namespaces are like folders. Use them to group related keys:

prefs.begin("wifi", false);
prefs.putString("ssid", "your-network");
prefs.putString("password", "your-password");
prefs.end();

prefs.begin("mqtt", false);
prefs.putString("broker", "192.168.1.50");
prefs.putInt("port", 1883);
prefs.putString("topic", "ctrlaltbrian/sensor/temp");
prefs.end();

The practical benefit: when you have 20 settings, namespaces let you clear() one without touching the others. Useful when the user wants to "reset Wi-Fi settings but keep my MQTT config."

prefs.begin("wifi", false);
prefs.clear();   // wipe everything in the "wifi" namespace
prefs.end();

You can also wipe all namespaces:

prefs.clear();   // must be inside a begin()/end() pair

The cost is a flash erase of the NVS partition, which takes ~1 second. Fine for a "factory reset" button; not fine for "every loop iteration."

Blob storage for larger values

For anything bigger than a string, use blobs:

struct CalibrationData {
  float offset;
  float gain;
  int   sensorId;
};

CalibrationData cal = {0.5, 1.02, 42};

prefs.begin("cal", false);
prefs.putBytes("data", &cal, sizeof(cal));
prefs.end();

// Later, read it back:
CalibrationData calRead;
prefs.begin("cal", true);   // read-only
size_t bytesRead = prefs.getBytes("data", &calRead, sizeof(calRead));
prefs.end();

if (bytesRead != sizeof(calRead)) {
  // Either no data stored, or stored data is wrong size
  // (struct shape changed across firmware versions)
}

Blob limits:

  • Max 4 KB per key (firmware limit; not the partition limit).
  • Max ~508 KB total across all blobs in a namespace (depends on partition size).
  • Max blob size is set by nvs_set_blob in the underlying ESP-IDF; the wrapper does not surface this directly.

For larger data, use the file system (LittleFS or FAT) instead.

Wear-leveling (the "is this safe to write often?" question)

NVS is built on top of flash, and flash wears out after ~10,000-100,000 erase cycles per sector. NVS solves this with wear-leveling: instead of writing to the same flash sector every time, the library rotates through available sectors. A single key can be written hundreds of thousands of times before flash wear becomes a problem.

Realistic numbers:

  • Updating a Wi-Fi password once a month: ~100,000 writes over 800 years. Not a concern.
  • Updating a sensor reading every second for logging: ~30 million writes over a year. The chip dies from something else first, but if you log this fast, you should be using a batching pattern (write every 100 readings, not every 1).
  • Updating a counter every loop iteration: maybe 10 million writes per day. This is borderline; use RTC memory for boot counters and only flush to NVS periodically.

The rule: do not write to NVS in loop(). Write on event ("Wi-Fi connected," "settings changed," "user pressed button").

When to use NVS vs RTC memory vs EEPROM

For an ESP32, you have three persistence options. For an Arduino Uno, the EEPROM emulation pattern is the equivalent.

Use case Pick Why
Wi-Fi credentials NVS Must survive power cycle, user changes it
MQTT broker address NVS Same
Calibration constant NVS Same, plus the value changes occasionally
Boot counter RTC memory Fast on wake, does not need to survive power cycle
Last sensor reading RTC memory Same
Sensor log (every minute) NVS or LittleFS NVS for short logs, LittleFS for long ones
Large calibration table LittleFS file Blob limit is 4 KB
Configuration backup (JSON) NVS (string) Survives OTA, easy to inspect with nvs_tool

The boundary: if you need it before Wi-Fi is up, RTC memory. If you need it to survive a power cycle, NVS. If it is too big for NVS, LittleFS.

When something breaks

  • "Put returns OK but value is gone after reboot." You forgot to call prefs.end(). Without end(), the write is buffered and lost on power cycle. Add end() before setup() returns (or after the last put*).
  • "Get returns the default every time even though I just put." The namespace name in begin() does not match the one you used to put. Namespaces are case-sensitive.
  • "Type mismatch on read." You putInt'd a key, then getFloat'd it. NVS tracks the type per key; reading as a different type returns the default with no error.
  • "NVS is full, put fails." The NVS partition has run out of space. Either wipe unused namespaces with clear(), or repartition with a larger NVS partition in the partition table.

What to build next

  • A Wi-Fi configuration portal: ESP32 starts in AP mode, serves a web page, accepts SSID and password, saves to NVS, then reboots into station mode and connects. The NVS write is what makes the configuration survive the reboot.
  • A "reset to defaults" function that wipes specific namespaces (e.g. clear "wifi" but keep "mqtt"). Wire to a physical button held for 10 seconds.
  • A calibration routine that takes 100 readings, computes the mean and standard deviation, and saves both as a blob. Read back on every boot for the sensor math.
  • The RTC memory tutorial (esp32-rtc-memory) for things that do not need to survive power cycles, like boot counters and last-known values.

Chapter 61

ESP32: sign OTA firmware updates so attackers cannot push their own

esp32 · 30 min

I had OTA working on a product for about a year before I thought about signing. The setup was straightforward: ESP32 hits my update server over HTTPS, downloads a new firmware.bin, verifies the SHA-256 hash matches what the server told it to expect, writes to the OTA partition, reboots into the new image. The HTTPS connection is encrypted. The hash check catches tampering. What is the problem?

The problem is that both ends of that conversation trust my update server, and "trust my update server" means trusting whatever server URL I baked into the firmware. If an attacker can swap that URL (e.g. by reflashing the ESP32, by DNS poisoning on the local network, by compromising the server itself), they can serve their own firmware.bin, their own SHA-256, and the device will install it. HTTPS protects the bytes on the wire. It does not protect you from a malicious server you have already decided to trust.

Signing closes the gap. The device holds a public key burned into the firmware at build time; the update payload is signed by the matching private key on the build machine; the device verifies the signature before installing. An attacker who controls the network or the server still cannot produce a valid signature, so the device rejects the update. This tutorial is the workflow I use.

Why unsigned OTA is dangerous (the MITM attack)

Walk through the attack with me:

  1. You ship an ESP32 that connects to https://updates.example.com/firmware.bin.
  2. An attacker on the same WiFi (coffee shop, hotel, conference) runs a DNS spoof or ARP spoof and redirects that hostname to their own server.
  3. Their server returns a firmware.bin that is a modified version of yours, with a backdoor in the MQTT handler that exfiltrates WiFi credentials.
  4. They also return a SHA-256 hash that matches their modified image.
  5. Your device compares the hash it computed against the hash it received from the server. They match. Update installs.

HTTPS protects steps 1-3 from a network eavesdropper. It does not protect step 5 from an attacker who is also the server. The device has no way to know whether the hash it was told to expect came from you or from them.

A signed update makes step 4 impossible. The device holds your public key. The update payload must contain a valid signature from your private key. The attacker does not have your private key, so their payload is rejected at signature-check time, regardless of what HTTPS did or did not do.

Generating a signing key

Use the same espsecure.py tool from the OTA ecosystem:

espsecure.py generate_signing_key --version 2 ota_signing_key.pem

The key is a 3072-bit RSA key (default). Save it as ota_signing_key.pem somewhere outside the repo (a secrets manager, an encrypted USB stick, never git). Treat it like a TLS private key.

You can use the same key for secure boot and OTA, or keep them separate. I keep them separate. Secure boot keys rarely need to rotate; OTA keys may rotate once a year or once a quarter as the device fleet grows. Separate keys means a leaked OTA key does not compromise your ability to ship new secure boot images.

Distributing the public key

The device needs the public half of the key to verify signatures. Two ways to ship it:

  1. Bundle as a C array in the firmware. Compile the PEM into a byte array at build time. The device carries the public key for its whole life. To rotate, you have to push a firmware update that contains the new public key (signed by the old one, of course, otherwise the update is rejected).

  2. Store in NVS / flash. Write the public key to a non-volatile storage region on first boot. Future updates read it from there. Easier to rotate, harder to lose.

For most projects, option 1 (bundled) is fine. Rotation only matters if you suspect the key was leaked, and by then you should be doing a full recall-and-rebuild, not just rotating keys.

#include <pgmspace.h>

// Generated with: openssl rsa -in ota_signing_key.pem -pubout -outform DER | xxd -i
// Replace this with your actual key bytes (2048 bytes for RSA-2048,
// 4224 bytes for RSA-3072).
const uint8_t ota_public_key[] PROGMEM = {
  0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xc4, 0x77, ...
};

const size_t ota_public_key_len = sizeof(ota_public_key);

The ArduinoOTA library (with signing hooks)

The standard ArduinoOTA library does not do signed updates out of the box. You have two options.

Option A: Use esp_https_ota with a signed payload.

The IDF-style esp_https_ota API can verify a signature on the received image. The Arduino IDE exposes this through HTTPUpdate (the HTTPClient ecosystem) with a callback:

#include <WiFi.h>
#include <HTTPUpdate.h>
#include <Update.h>

// Signature verification callback. Return true to install the image,
// false to reject it.
bool verify_signature(uint8_t* payload, size_t len) {
  // Compute SHA-256 of the payload
  uint8_t hash[32];
  mbedtls_sha256_context ctx;
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);   // 0 = SHA-256, not SHA-224
  mbedtls_sha256_update(&ctx, payload, len);
  mbedtls_sha256_finish(&ctx, hash);

  // Verify the signature (assume it was appended to the payload,
  // 384 bytes for RSA-3072)
  const size_t sig_len = 384;
  if (len < sig_len) return false;
  const uint8_t* signature = payload + (len - sig_len);
  const size_t image_len  = len - sig_len;

  // Re-hash just the image portion
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);
  mbedtls_sha256_update(&ctx, payload, image_len);
  mbedtls_sha256_finish(&ctx, hash);

  return mbedtls_rsa_pkcs1_verify(
    &ota_public_key_rsa_ctx,         // populated elsewhere
    MBEDTLS_MD_SHA256,
    hash, 0,
    signature
  ) == 0;
}

void performOTA() {
  WiFiClientSecure client;
  client.setCACert(root_ca);          // TLS validation (see esp32-tls-in-depth)
  // ... fetch the firmware, then before installing:
  if (!verify_signature(firmware_buffer, firmware_len)) {
    Serial.println("Signature check failed, refusing update");
    return;
  }
  Update.write(firmware_buffer, firmware_len);
}

This is more code than ArduinoOTA but it gives you the actual security guarantee.

Option B: Use ArduinoOTA + a wrapper that checks the signature out of band.

For simpler projects, keep ArduinoOTA for the transfer and add a second HTTP call to a separate endpoint that returns just the signature for the version you are about to install. The device fetches firmware.bin, fetches firmware.bin.sig, verifies locally, then installs. The signature endpoint has to be on a different origin (different URL, different TLS cert) from the firmware endpoint, so a compromise of one does not give the attacker both pieces.

I use option B for hobby projects and option A for products.

The partition table for OTA

OTA needs two app partitions so the new image can be written while the old one is still running. The default ESP32 partition scheme already has this. Tools >> Partition Scheme >> "Default 4MB with OTA" gives you:

nvs      0x9000   0x5000
otadata  0xe000   0x2000
app0     0x10000  0x180000
app1     0x190000 0x180000
spiffs   0x310000 0xF0000

otadata is the partition that tracks which app slot to boot next. After a successful OTA, the bootloader flips a flag there. If the new image fails to boot (crash loop within the first few seconds), the bootloader reverts to the old slot.

To see the current partition layout, hold the boot button while plugging in, or check Tools >> Partition Scheme in the IDE.

Rollback protection (the "stay on the working image" feature)

Update has a magic byte you can set in the OTA image to mark it as "valid." If the new image never sets that byte, the bootloader rolls back on next boot. The pattern is:

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Do your setup. If everything is good and the device is talking
  // to its cloud endpoint, mark this image as valid.
  if (everythingWorks()) {
    Update.setBootValid();   // <-- the line that prevents rollback
  }
}

void loop() {
  // ...
}

If the new image crashes before reaching Update.setBootValid(), the bootloader rolls back on the next boot. The device ends up on the previous image, not stuck in a crash loop. This is the safety net that lets you ship OTA updates without bricking the field.

Anti-rollback bit (the secure boot companion)

If you are using secure boot (see esp32-secure-boot), the chip also supports an anti-rollback counter stored in efuse. Each firmware image can declare "I require firmware version N or later." The chip will refuse to boot any image whose declared version is lower than what is burned in efuse.

This means an attacker cannot:

  • Capture an old (vulnerable) image from a device in the field
  • Reflash that old image onto an updated device
  • Hope the chip accepts it

The anti-rollback counter increments each time you ship a new version. Burn it via:

espefuse.py --port /dev/ttyUSB0 burn_anti_rollback --max 1

This sets the chip to refuse any image older than version 1. Each new firmware release increments the version number, and a CI step burns the new value into efuse before shipping. For OTA-only flows (no physical access to the device), you can do the same trick by including the version number in the signed payload and having the bootloader check it on every boot.

When something breaks

  • "Signature verification failed" every time. Your key bytes are wrong. Re-export with openssl rsa -in ota_signing_key.pem -pubout -outform DER | xxd -i and confirm the output matches what is in the source. Off-by-one byte truncation will cause this every time.

  • OTA succeeds but the device crashes in a loop. Your rollback protection is working. The new image has a bug. Fix the bug, re-sign, re-push. The device should auto-revert to the old image.

  • The bootloader is stuck on the old slot. You forgot Update.setBootValid() somewhere reachable in the new image, or the new image crashes before reaching it. Reflash over serial to recover.

  • Updating the signing key failed. You cannot push a key rotation over the air unless the new key is signed by the old one. Plan rotations as "ship a firmware update with both old and new public keys, the update prefers the new one, the next OTA uses the new one." It is doable but not trivial.

What to build next

  • A CI build that runs espsecure.py sign_data on every release artifact and fails the build if signing fails. The signed binary is what you push to the OTA server.
  • A monitoring endpoint on your OTA server that tracks which firmware version each device is on, with alerts for "more than X% of devices failed to apply update N."
  • The full secure boot + signed OTA pipeline for products shipping into customer hands. The esp32-secure-boot tutorial covers the chip side; this one covers the payload side.

Chapter 62

ESP32: oscilloscope basics, what those waveforms actually mean

esp32 · 25 min

The first time I borrowed an oscilloscope, I turned it on, looked at a blinking LED's GPIO line, saw a square wave, and thought "great, that matches what I expected." Then I tried to measure a PWM signal, the trace looked weird, and I spent an hour adjusting knobs before I understood what "volts per division" and "time per division" actually meant. This tutorial is the 30-minute version of what I learned that day.

You do not need an oscilloscope for most ESP32 projects. When you do need one, it is for power supply noise, PWM signal verification, analog sensor outputs, and signal integrity on long wires. This tutorial covers what a scope measures, the controls you need to know, and the workflows I use weekly.

What an oscilloscope measures

An oscilloscope plots voltage over time. One or more channels probe a point in your circuit; the scope samples the voltage at high rate and renders it on the screen. You see the actual waveform, not just "high" or "low."

Two numbers define what a scope can do:

  • Bandwidth: the highest frequency signal the scope can measure accurately. A 100 MHz scope can show a 100 MHz sine wave at about 70% of its true amplitude (the -3 dB point). To measure a 100 MHz signal accurately, you want a scope with 3-5x the bandwidth.
  • Sample rate: how many voltage measurements the scope takes per second. A 1 GSa/s scope takes a billion samples per second. Rule of thumb: sample rate should be at least 4-5x the highest frequency in your signal so the waveform looks smooth.

For ESP32 work, signals are slow (PWM is at most a few MHz, I2C is typically 100-400 kHz, SPI is usually under 10 MHz). A 100 MHz scope with 1 GSa/s sample rate is plenty.

The cheap options

  • Rigol DS1054Z ($350 used, $400 new): the standard hobbyist scope. 4 channels, 50 MHz bandwidth (officially upgradeable to 100 MHz via a paid firmware key from Rigol for about $99), 1 GSa/s. The unofficial hack that unlocks 100 MHz without paying is well- documented online, but it technically violates the EULA, so do not do it; pay for the official upgrade. Most lab benches I know have one of these.
  • Siglent SDS1104 ($300): similar capability to the Rigol, 4 channels, 100 MHz, 1 GSa/s. Siglent is the more conservative Rigol alternative.
  • FNIRSI 1C15 ($100): a cheap handheld scope that is genuinely useful for low-frequency work. 1 channel, 100 MHz bandwidth, battery-powered. Good for field debugging where dragging a bench scope is not practical.
  • Hantek DSO5102P ($200): older 2-channel 100 MHz scope. Decent, but the UI is dated and the build quality is mediocre.
  • PicoScope 2204A ($150): USB scope, 2 channels, 10 MHz bandwidth. The bandwidth is low but the software is genuinely good. Good for laptop-based debugging.

For your first scope, the Rigol DS1054Z is the right pick. You will not outgrow it for a long time.

Voltage vs time (the axes)

Two knobs control what you see on the screen:

  • Volts per division (vertical axis): how many volts each square on the screen represents. Set this so the waveform fills most of the screen vertically. If your signal is 3.3V and volts per division is 1V, the waveform takes up about 3 squares of the screen. If volts per division is 5V, the waveform is two- thirds of one square and hard to read.
  • Time per division (horizontal axis): how many seconds each square on the screen represents. Set this so you see enough cycles of the waveform to understand its shape. If your signal is a 1 kHz square wave and time per division is 1 ms, you see one cycle per square. If time per division is 100 us, you see ten cycles per square.

The relationship: volts per division is the "vertical zoom," time per division is the "horizontal zoom." Adjust them independently until the waveform is clear.

The key controls (the ones you actually use)

There are 20+ knobs on a typical scope. Most people use four:

  1. Volts per division (per channel): sets the vertical scale.
  2. Time per division: sets the horizontal scale.
  3. Trigger level: sets the voltage at which the scope starts drawing the trace. Without a trigger, the waveform scrolls across the screen because the scope does not know where "the start" of a cycle is. With a trigger, the scope waits for the signal to cross the trigger voltage (e.g. the rising edge of a 3.3V signal crossing 1.65V), then draws the waveform anchored at that point. Set the trigger to the middle of your signal's voltage range, on the channel you are probing, on the edge you want to align to.
  4. Run / Stop: starts or stops the capture. "Single" captures one trace and stops, useful for one-shot events.

That is it for 90% of debugging. Every other control is fine to ignore until you hit a specific problem.

AC vs DC coupling

The coupling selector on each channel is one of the most misunderstood controls. It has three positions:

  • DC coupling (default): the scope shows the actual voltage at the probe, including any DC offset. A 3.3V supply rail shows as a flat line at 3.3V. A PWM signal swings between 0V and 3.3V.
  • AC coupling: the scope blocks the DC component and only shows the AC (changing) part. A 3.3V supply rail shows as a flat line at 0V. A PWM signal with a 1.65V average shows the ripple around 0V. Use AC coupling to see small AC signals riding on a large DC offset (e.g. power supply ripple on a 3.3V rail).
  • GND: the scope disconnects the input and grounds it, so you see exactly where 0V is on the screen. Use this to verify the zero line before making measurements.

For most ESP32 work, DC coupling is correct. AC coupling is for measuring ripple on a power rail.

What a clean waveform looks like

A clean 1 kHz square wave from an ESP32 GPIO looks like:

  • Flat at 0V most of the time, then jumps to 3.3V, then falls back to 0V.
  • The rising edge is a vertical line (or close to it; in reality it has a small slope because the GPIO has finite drive strength).
  • The falling edge is the same in reverse.

A noisy or distorted square wave shows:

  • Ringing on the edges (a damped sine wave on the rising or falling transition, usually caused by inductance in the wire
    • capacitance in the load).
  • Overshoot (the signal goes above 3.3V on the rising edge before settling).
  • Undershoot (the signal goes below 0V on the falling edge).
  • Glitches (unexpected transitions in the middle of a "flat" region).

These are all symptoms of impedance mismatch, long wires, or missing decoupling. The scope shows them; Serial.print does not.

Measuring PWM duty cycle

PWM duty cycle is the percentage of time the signal is high in one cycle. To measure it:

  1. Probe the GPIO that outputs the PWM signal.
  2. Set time per division so you see 2-3 cycles of the waveform.
  3. Most scopes have a "Measure" menu with built-in duty cycle measurement. Pick the channel, pick "Duty Cycle," read the percentage off the screen.

If your scope does not have auto-measure, use the cursors:

  • Cursor 1 on the rising edge of one cycle.
  • Cursor 2 on the falling edge of the same cycle.
  • Cursor 3 on the next rising edge (defines the period).
  • The ratio of (cursor2-cursor1) to (cursor3-cursor1) is the duty cycle.

The expected value: duty / 255 * 100 for 8-bit PWM, duty / 1023 * 100 for 10-bit PWM. If you see a value off by more than a few percent, the LEDC configuration is wrong.

Measuring rise time

Rise time is the time the signal takes to go from 10% to 90% of its final value. It is a measure of how fast the signal can change, and it tells you whether your GPIO drive strength is adequate for the load.

To measure rise time:

  1. Probe the signal.
  2. Trigger on the rising edge.
  3. Zoom in horizontally (time per division of 10 ns or 100 ns depending on the signal) so you can see the rising edge in detail.
  4. Use the cursors: one at the 10% point, one at the 90% point.
  5. Read the delta.

A clean ESP32 GPIO output has a rise time of a few nanoseconds. If you see 50 ns rise time, the load is heavier than the GPIO can drive quickly (e.g. long cable, big capacitor, weak pull-up). The fix is usually a series resistor, a smaller load, or a buffer.

What bandwidth means and why it matters

Bandwidth is the single most important spec on a scope. It determines what frequencies the scope can measure accurately.

If you probe a 100 MHz signal with a 100 MHz scope, the scope shows the signal at about 70% of its true amplitude. The waveform looks "softer" than it really is. Rise times appear longer than they really are.

Rule of thumb: scope bandwidth should be 3-5x the highest frequency in your signal. For a 1 MHz PWM signal, a 5 MHz scope is enough (and almost any scope is). For a 100 MHz SPI bus, a 300-500 MHz scope is the right pick.

For ESP32 work specifically, signals are slow. A 100 MHz scope handles anything the ESP32 can produce. The 50 MHz DS1054Z is fine for GPIO and I2C; you only need more bandwidth for analog signals or fast external buses.

Scope probes (1x vs 10x)

Most scope probes have a switch on them: 1x or 10x. This is the attenuation factor.

  • 1x probe: the signal goes straight to the scope. Easy, but the probe adds capacitance to your circuit (~100 pF), which can affect high-impedance signals.
  • 10x probe: the signal is attenuated 10x before reaching the scope. The scope's volts per division effectively multiplies by 10 (most scopes do this automatically when you tell them you are using a 10x probe). The probe capacitance is much lower (~10 pF), so the loading on your circuit is much smaller.

For ESP32 GPIO work, 10x is the right pick. The signal is 3.3V which both probes can handle, but the 10x probe's lower capacitance means you do not distort the signal you are trying to measure.

To configure the scope for a 10x probe, go to the channel menu and set "Probe Attenuation" to 10x. Most probes have a small calibration loop on them: connect the probe tip to the cal terminal on the scope front panel, set the scope to a known square wave, and adjust the trimmer capacitor on the probe until the waveform is a clean square (no overshoot, no rolloff). Do this once when you first get the probe.

When something breaks

  • "The scope shows nothing." Check the probe ground clip. Check that the right channel is on. Check that the trigger level is reachable by the signal (if trigger is 5V and the signal is 3.3V, the scope never triggers and shows a flat line).
  • "The waveform scrolls across the screen." Trigger is wrong. Set the trigger source to the channel you are probing, the trigger level to the middle of the signal, and the edge to rising or falling.
  • "The waveform looks fuzzy." Either the probe is set wrong (1x vs 10x), the trigger is jittery (use "normal" trigger mode instead of "auto"), or the signal genuinely has noise.
  • "The voltage reading is wrong by 10x." Probe attenuation mismatch. Set the scope to match the probe (1x or 10x).

What to build next

  • A test circuit: an ESP32 GPIO driving an LED through a 220-ohm resistor. Probe the GPIO. See the PWM waveform. Measure the duty cycle.
  • The esp32-logic-analyzer tutorial if you do not have a logic analyzer. Scope and logic analyzer are the two bench tools you need; the rest is optional.
  • A simple RC low-pass filter on a PWM output to generate a DAC. Probe before and after the filter; see the PWM rectangle become a smooth DC level. This is how ESP32 DAC-less PWM DAC works.

Chapter 63

ESP32: drive an addressable RGB LED strip over RMT, the right way

esp32 · 30 min

Addressable RGB LED strips are why a lot of people buy an ESP32. The WS2812B is the common one: 5V power, one data line, one LED per "pixel," daisy-chainable. You can buy a 5-meter strip of 60 LEDs per meter (300 LEDs) for about $25 and control all of them from a single GPIO pin.

The trick is the timing. WS2812Bs expect a very specific waveform (800 kHz, with high and low pulses measured in hundreds of nanoseconds). The ESP32 has a peripheral called RMT (Remote Control) that is designed for exactly this. Bit-banging works on a Raspberry Pi Pico or an Arduino, but the ESP32's RMT is more reliable. Use it.

This tutorial covers the LEDs, the RMT, the FastLED library, the wiring (with the level-shifter caveat), the 5-meter power-injection pattern, the gamma correction pattern, and the always-on power math.

What you need

  • ESP32 dev board
  • WS2812B strip (5V, 30 or 60 LEDs per meter; the "B" in WS2812B is important; the older WS2811 has different timing)
  • 5V power supply rated for the strip (e.g. 5V 10A for a 5m strip of 60 LEDs per meter at full white)
  • Level shifter (3.3V to 5V) on the data line, e.g. SN74HCT125
  • Capacitor (470uF to 1000uF, 6.3V or higher) across the strip power pads
  • Resistor (330 to 470 ohm) on the data line, between the ESP32 and the strip
  • Three jumper wires

The LEDs: WS2812B, SK6812, APA102

There are three common addressable LED families:

  • WS2812B: the most common. 5V power, 800 kHz one-wire protocol, GRB color order, no clock line. Cheap and ubiquitous.
  • SK6812: similar to WS2812B but with better color consistency and optional white channel (RGBW). Pin-compatible with WS2812B in most cases.
  • APA102 (also called "DotStar"): 5V power, two-wire protocol (data + clock), higher refresh rate, more expensive. Used when the WS2812B timing is too unreliable (e.g. very long strips).

The FastLED library supports all three. The wiring for APA102 is different (it has a clock line in addition to data). For most projects, WS2812B is the right pick.

The RMT peripheral

The ESP32 has an RMT (Remote Control) peripheral that is designed to transmit and receive precisely-timed pulses. It is the right tool for WS2812Bs because the timing is critical and the CPU cannot be trusted to hit it under load (Wi-Fi, Bluetooth, other interrupts).

The older way was to bit-bang the protocol from the CPU. It worked on an ESP8266 and it sometimes worked on an ESP32, but under Wi-Fi load, the timing would slip and the LEDs would glitch.

The RMT peripheral handles the timing in hardware. You set up a channel, give it a buffer of pulse timings, and the peripheral transmits. The CPU is free to do other things.

The Arduino-ESP32 core has a built-in rmt library, but most people use the FastLED library (which uses the RMT under the hood on ESP32).

Install FastLED

Sketch >> Include Library >> Manage Libraries >> search FastLED >> install.

FastLED is the most popular library for addressable LEDs on Arduino- compatible boards. It supports the ESP32 RMT, has animation helpers, and has good documentation.

The wiring

ESP32 5V  ----+----- 5V+ on power supply
              |
              +----- 5V on WS2812B strip
ESP32 GND ----+----- GND- on power supply
              |
              +----- GND on WS2812B strip
ESP32 GPIO 5 --[330R]--[ SN74HCT125 ]--[330R]-- DIN on WS2812B strip

Three things in this diagram that are not optional:

  • The 330R resistor on the data line, right at the strip end. The WS2812B datasheet asks for it, and without it the first LED can flicker or, on a bad day, fry.
  • The level shifter (SN74HCT125 or similar) between the ESP32 and the strip. The WS2812B expects 0.7 * VCC = 3.5V for a logic high, and the ESP32 outputs 3.3V. That is close enough to work in many cases, but the level shifter is the safe move.
  • The bulk capacitor across the strip's power pads. WS2812Bs draw current in spikes (each LED can pull 60 mA when you turn it on white), and without bulk capacitance you get brownouts that reset the ESP32.

The level shifter is the part most people skip. It works without it, until it does not. For a small strip on a breadboard, skip it. For a permanent install, add it.

The FastLED code

#include <FastLED.h>

#define LED_PIN     5
#define NUM_LEDS    60
#define BRIGHT      60   // 0-255, 60 is sane for indoor eye-candy

CRGB leds[NUM_LEDS];

void setup() {
  FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(BRIGHT);
  FastLED.clear();
  FastLED.show();
}

void loop() {
  fill_rainbow(leds, NUM_LEDS, 0, 7);
  FastLED.show();
  delay(20);
}

The addLeds<WS2812B, LED_PIN, GRB> line tells FastLED which LED type, which pin, and which color order. WS2812Bs use GRB (green-red- blue) in the wire protocol, not RGB. FastLED handles the byte swapping.

The BRIGHT constant is the global brightness cap. The FastLED library multiplies every color by BRIGHT/255 before sending, so BRIGHT=60 keeps the current to about 14 mA per LED instead of 60 mA per LED.

Gamma correction

The WS2812B is linear: CRGB(128,128,128) outputs half the brightness of CRGB(255,255,255). But the human eye is logarithmic: half the perceived brightness is more like CRGB(188,188,188). The result is that dim levels look "stepped" - the jump from 1 to 2 is invisible, the jump from 200 to 201 is huge.

The fix is gamma correction: a lookup table that maps 0-255 (linear) to 0-255 (perceived linear). FastLED has it built in:

#include <FastLED.h>

void setup() {
  // Turn on gamma correction
  FastLED.setCorrection(TypicalLEDStrip);
  // ... rest of setup
}

The TypicalLEDStrip is one of several built-in corrections. Pick the one that matches your LED type. For WS2812B, TypicalLEDStrip or TypicalSMD5050 are both good. The difference is small.

Without gamma correction, fades look like they have a "dead zone" at the bottom. With gamma correction, fades look smooth.

The 5-meter power injection pattern

A 5-meter strip of 60 LEDs per meter has 300 LEDs. At full white, that is 18 amps. The strip's power wire is not rated for 18 amps (usually 22 AWG, rated for about 5 amps). The result is voltage drop: the LEDs at the end of the strip get dimmer and may glitch.

The fix is to inject power at both ends:

Power supply 5V ---+----- 5V pad on start of strip
                    |
                    +----- 5V pad on end of strip (separate wire)
Power supply GND --+----- GND pad on start of strip
                    |
                    +----- GND pad on end of strip

The data line still daisy-chains through the strip. The power comes from both ends.

For longer strips (10+ meters), inject power every 2-3 meters.

The 800-LED limit

The ESP32's RMT buffer is fixed-size. Each LED takes about 24 bits in the buffer (8 bits per channel, 3 channels). 800 LEDs at 24 bits is 19,200 bits. The default RMT buffer on the ESP32 is 64 KB (8,192 32-bit words), so 800 LEDs is about the maximum.

You can push the limit by reducing the RMT buffer size and accepting slower refresh rates, but the practical limit is about 800 LEDs with FastLED on an ESP32. For more, use multiple ESP32s, use an external LED driver, or use a different protocol (e.g. APA102 with a hardware clock).

For 95% of projects, 800 LEDs is plenty. A 5m strip at 60 LEDs/m is 300 LEDs, well under the limit.

Animation patterns

FastLED has a few built-in helpers:

// Rainbow: cycles through the spectrum
fill_rainbow(leds, NUM_LEDS, hue, delta_hue);

// Theater chase: a single "lit" pixel moves down the strip
addGlitter(80, leds, NUM_LEDS);   // adds random sparkles
fill_solid(leds, NUM_LEDS, CRGB::Red);  // all red

// Custom: each LED is a different color
for (int i = 0; i < NUM_LEDS; i++) {
  leds[i] = CHSV(i * 10, 255, 255);   // hue, saturation, value
}
FastLED.show();

The CHSV (hue, saturation, value) color model is more useful for animations than CRGB (red, green, blue). The hue is a 0-255 number that wraps around the color wheel. Hue 0 is red, hue 85 is green, hue 170 is blue. Incrementing the hue gives a smooth rainbow.

The "always-on LED strip" power math

A WS2812B at full white draws about 60 mA. A 60-LED strip at full white draws 3.6 amps. A 300-LED strip (5m at 60 LEDs/m) at full white draws 18 amps.

This is the part people forget when they install a strip. A "5V 3A" phone charger can run about 50 LEDs at full white. A "5V 10A" laptop brick can run about 165 LEDs at full white. For a full 5m strip at full white, you need a real supply (the 5V 20A "LED power supply" sold on Amazon is the typical pick).

The setBrightness(60) cap keeps the current to about 14 mA per LED, which means a 300-LED strip draws about 4.2A. That is a manageable power supply.

The takeaway: a 5m strip is a 100W load. Treat it like a 100W load when you plan the power supply and the wiring.

What you learned

  • The RMT peripheral on the ESP32 is the right way to drive WS2812Bs. No bit-banging.
  • The data line needs a level shifter (3.3V to 5V) for a permanent install. The 330R resistor is not optional.
  • 5V power injection at both ends of a long strip prevents voltage drop.
  • Gamma correction makes fades look smooth. Without it, dim levels look stepped.
  • A 300-LED strip at full white draws 18 amps. Plan the power supply.

When something breaks

The first LED flickers or does not light up. Data signal is borderline. Add the level shifter, or shorten the data wire.

The strip is dim at the end. Voltage drop. Inject power at both ends.

The strip resets when you turn on white. Brownout. Add the bulk capacitor across the strip's power pads.

FastLED compilation fails on ESP32-S3. The RMT API changed. Update FastLED to 3.6 or newer.

The colors are wrong. The color order is wrong. WS2812B is GRB; some cheap clones are RGB. Change the third parameter in addLeds<WS2812B, LED_PIN, GRB> to RGB.

CRGB(255,255,255) is not actually white. The LEDs are individually calibrated, but the strip has variation. Set FastLED.setCorrection(TypicalLEDStrip) and the variation becomes less obvious.

What to build next

  • A music-reactive strip with a MAX4466 microphone.
  • A Wi-Fi controlled strip with a web UI.
  • A "fire" effect with red/orange flickering.
  • A 16x16 matrix for text and animations.

The music-reactive version is the most fun. The matrix is the project that uses the most LEDs (256 in a 16x16 grid).


Chapter 64

ESP32: RTC memory, the fast persistent storage that survives deep sleep

esp32 · 20 min

The first battery-powered sensor I built woke up every 5 minutes, read the temperature, sent it over MQTT, and went back to sleep. The thing that surprised me: every time it woke up, it had no idea how many times it had woken up before. The boot counter reset to zero on every wake. I was logging the wrong thing for two days before I figured out why.

The ESP32 has a small chunk of memory that survives deep sleep. It is called RTC memory (because it is in the real-time clock domain, which stays powered even when the rest of the chip is asleep). You declare variables in it with the RTC_DATA_ATTR attribute, and they keep their values across esp_deep_sleep_* calls. This tutorial is about when to use it, when not to, and the gotcha that bites every first-timer.

What RTC memory actually is

Inside the ESP32, there are several memory regions:

  • DRAM: regular RAM. Wiped on deep sleep, wiped on power cycle. This is where your variables live.
  • RTC slow memory: 8 KB on the classic ESP32, 16 KB on the ESP32-S3. Stays powered during deep sleep, wiped on power cycle.
  • RTC fast memory: 8 KB on the classic ESP32, used by the ULP coprocessor. You almost never touch this from Arduino code.
  • Flash: several MB. Survives everything. Slow to write, wears out after 10,000-100,000 erase cycles per sector.

When you write RTC_DATA_ATTR int counter = 0;, the variable goes in RTC slow memory. After esp_deep_sleep_start(), the chip powers down DRAM but keeps RTC memory powered. When it wakes, the variable still has the value it had when you went to sleep.

If you cut power entirely (yank the battery, power cycle the board), RTC memory resets. It is not "non-volatile" in the "survives a power cycle" sense. It is "non-volatile across deep sleep." That distinction matters.

The RTC_DATA_ATTR attribute

The pattern is one keyword:

#include <WiFi.h>

RTC_DATA_ATTR int bootCount = 0;
RTC_DATA_ATTR float lastTemperature = -999.0;
RTC_DATA_ATTR char lastWill[64] = "";

void setup() {
  Serial.begin(115200);
  delay(1000);

  bootCount++;
  Serial.print("Boot #");
  Serial.println(bootCount);

  // ... read sensor, save to lastTemperature, etc. ...

  Serial.println("Going to sleep");
  esp_deep_sleep_start();
}

void loop() {
  // never reached
}

After every esp_deep_sleep_start(), the chip resets. On wake, bootCount has the previous value plus one. lastTemperature has the value you wrote before sleep. No setup needed, no library to include beyond the ESP32 core.

The use cases

Three patterns where RTC memory is the right tool:

Boot counters and uptime tracking. Every deep-sleep wakeup increments the counter. After a year of waking every 5 minutes, you have ~100,000 boots. Useful for "how long has this sensor actually been deployed" diagnostics.

Last-known sensor value. Before going to sleep, write the last sensor reading to RTC memory. On wake, before the new sensor read completes (which takes time), the dashboard or MQTT client can show the previous value as a placeholder. No "no data" gaps in the chart.

State machine across sleep. If the sensor has modes ("calibrating," "sending," "idle"), the mode persists across deep sleep. The chip wakes up knowing what it was doing.

Last-will-style messages. If the chip is supposed to publish a "going offline" MQTT message before sleep, but the network is unreliable, write the intended message to RTC memory first. On the next boot, if the message was never published, send it now. This pattern handles the "chip lost power before it could send the goodbye" edge case.

The size limit

The slow RTC memory region is small:

Chip Slow RTC size
ESP32 (original, WROOM, WROVER) 8 KB
ESP32-S2 8 KB
ESP32-S3 16 KB
ESP32-C3 8 KB

8 KB sounds like a lot until you remember it is shared with the UART, the Wi-Fi stack's deep-sleep state, and a few other things. Realistically, you have 4-6 KB for your own variables.

If you declare a 2 KB RTC buffer, you have used 25% of the region. The ESP-IDF will warn at compile time, then fail at runtime if you go over. Keep your RTC variables small and counted.

For larger persistent state, use NVS (covered next) or flash.

The "value persists across deep sleep but not power loss" gotcha

This is the bug that catches everyone once.

The setup: a sensor on a battery. The chip wakes, reads the sensor, sends the value, goes back to sleep. You write the sensor ID to RTC memory so you can read it back. After debugging, you swap the battery to test low-voltage behavior.

Now the chip powers up. The boot counter is zero. The sensor ID is gone. The RTC memory reset because the battery was disconnected.

If your code assumes the sensor ID is always present in RTC memory, it now reads garbage (or zero) and misbehaves. The fix is to either:

  1. Validate the RTC values on boot. If they look like default (zero, uninitialized memory pattern), initialize them.
  2. Use NVS for anything that must survive a power cycle.

The validation pattern looks like:

RTC_DATA_ATTR uint32_t magic = 0;
RTC_DATA_ATTR int bootCount = 0;

void setup() {
  if (magic != 0xCAFEBABE) {
    // First boot, or RTC memory was wiped
    magic = 0xCAFEBABE;
    bootCount = 0;
  }
  bootCount++;
  // ...
}

The magic-number check is cheap and catches both "never written" and "wiped by power cycle." The cost is 4 bytes of RTC memory, which you have plenty of.

When to use RTC memory vs NVS vs flash

Three options, ordered by access speed:

Storage Use case Speed Persists across Wear
RTC memory Boot counters, last-known values, mode state Fastest (nanoseconds) Deep sleep only None (it's RAM)
NVS Wi-Fi credentials, calibration values, settings Slower (milliseconds) Deep sleep + power cycle Wear-leveled (10 years typical)
Flash files Logs, OTA images, large data Slowest (milliseconds to seconds) Everything Per-sector wear (10,000-100,000 cycles)

For things the chip needs immediately on wake (before Wi-Fi, before flash), RTC memory is the right pick. Reading NVS takes a few milliseconds; reading a flash file takes tens of milliseconds. If you want the boot counter printed in the first line of setup(), RTC memory is the only option that lets you do it in zero time.

For things the user changes (Wi-Fi password, MQTT topic, calibration constant), use NVS. The user might power-cycle the device and expect their settings to be there.

For things that accumulate over time (logs, sample buffers), use flash files with a wear-aware write pattern.

When something breaks

  • "RTC memory resets on every wake." You are using RTC_DATA_ATTR on a regular variable, but the chip is doing a power-on reset, not a deep sleep wake. Check that esp_deep_sleep_start() is actually being called and that the reset reason is ESP_SLEEP_WAKEUP_UNDEFINED (power-on) vs ESP_SLEEP_WAKEUP_TIMER (deep sleep wake). Add Serial.println(esp_sleep_get_wakeup_cause()); to confirm.
  • "Compile error: section .rtc_noinit' will not fit`." You declared too much RTC memory. The classic ESP32 has 8 KB and the rest is used by ESP-IDF. Trim your buffers or move large state to NVS.
  • "Values look like random garbage on the first boot." RTC memory is not initialized on cold boot. The first read after power-up might be anything. Always check the magic number (or a sensible value range) before using the data.

What to build next

  • A deep-sleep sensor that publishes a "wake counter" to MQTT once per day, so you can verify the chip is actually waking and sleeping on schedule without watching the serial monitor.
  • A "last-known-value" pattern: on every wake, send the previous reading alongside the new one. The dashboard can fill in gaps with the previous value when the network is flaky.
  • The NVS tutorial (esp32-nvs-storage) for storing things that must survive a power cycle: Wi-Fi credentials, calibration values, the device name.
  • The deep sleep tutorial (esp32-deep-sleep) if you have not read it yet. RTC memory is most useful paired with deep sleep, but the two are independent (you can use RTC memory without deep sleep if you want, though the values still reset on power cycle).

Chapter 65

ESP32: secure boot and flash encryption, the real product build

esp32 · 30 min

I shipped an ESP32 product for two years before turning on secure boot. The reason I put it off was simple: the documentation made it sound like a one-way ticket to a bricked board. It kind of is, but "one-way" is the point. If someone can reflash your board, the firmware you spent six months writing is also theirs. This tutorial is the workflow I use now, including the part where you brick a board on purpose so you know what recovery looks like before it happens on a customer's desk.

This is for products you ship. If your ESP32 sits on your desk and never leaves, skip the whole thing.

What secure boot actually guarantees

Secure boot does one thing: it makes the chip refuse to boot any firmware that is not signed by a key whose public half is burned into efuse (a one-time programmable region of the chip). If you do not have the private key, you cannot produce a firmware image that the chip will accept. Period.

It does not encrypt your firmware (that is flash encryption, see below). It does not protect the firmware at rest (an attacker with the chip in hand can read the signed image; they just cannot replace it with their own). It does not protect the data your firmware handles (you still need application-layer crypto for that).

What it does do: stop anyone with physical access from putting their own firmware on your device. For most products that is the main threat model, and it is the one secure boot addresses.

The efuse approach

Efuses are bits in the chip that can be flipped from 0 to 1 one time. There is no un-flip. The ESP32 has several efuse blocks:

  • BLOCK0: system config (flash voltage, boot mode, etc.)
  • BLOCK1: secure boot key (the SHA-256 hash of your signing key)
  • BLOCK2: flash encryption key
  • BLOCK3: user data (you can use this for anything)

For secure boot, you generate a key pair, compute the SHA-256 hash of the public key, and burn that hash into BLOCK1. The chip then checks every firmware image's signature against that hash on every boot.

The key pair lives on your build machine (or in CI). It is not stored on the chip. If you lose it, you cannot sign new firmware. Treat it like a TLS private key: versioned, backed up, never checked into git.

Generating keys

The official tool is espsecure.py from the ESP-IDF toolchain. Arduino users get it when they install the ESP32 board package; the binary lives in ~/.arduino15/packages/esp32/tools/esp32-arduino-libs-*/ somewhere under tools/espsecure/.

Generate the key:

espsecure.py generate_signing_key --version 2 secure_boot_signing_key.pem

The --version 2 flag is the RSA-PSS scheme. Use it. Version 1 (the legacy scheme) is deprecated.

Back up the resulting secure_boot_signing_key.pem file to three places (your laptop, an encrypted USB stick, an offline backup). You cannot rotate this key on a device that is already in the field; that is what the rollback protection in the OTA tutorial is for.

Flashing via espefuse

With the key in hand, you compute the digest and burn it:

espsecure.py digest_signing_key --key secure_boot_signing_key.pem \
  --output signing_key_digest.bin

espefuse.py --port /dev/ttyUSB0 burn_key BLOCK_KEY0 \
  signing_key_digest.bin

Two things to notice:

  1. --port points to the USB serial port your dev board exposes. On Windows that is COM3 or similar; on macOS it is /dev/cu.usbserial-*; on Linux it is /dev/ttyUSB0.
  2. The --port argument uses = style here for clarity. The actual flag is --port, which can be -p for short.

After this command runs, BLOCK1 has the key digest and it cannot be unset. Read back the efuses to confirm:

espefuse.py --port /dev/ttyUSB0 summary

You should see ABS_DONE_0 = 1 (the secure boot "abstract" bit) and BLOCK_KEY0 = (the hash). Once ABS_DONE_0 is 1, the chip will not boot unsigned images, ever. If your signing key is wrong or lost, the only recovery is physical replacement of the chip.

Test on a board you are willing to lose first. I keep a dedicated "secure boot test" board for exactly this. The first time you burn a key and the board refuses to boot, you do not want that moment to be on the unit you need for a demo.

The build flags

In the Arduino IDE, secure boot is enabled per-board via the "Tools >> Secure Boot" menu. Select "Enabled." In PlatformIO, set board_build.secure_boot = enabled in platformio.ini.

For ESP-IDF, set CONFIG_SECURE_BOOT=y and CONFIG_SECURE_BOOT_V2_ENABLED=y in sdkconfig (or via idf.py menuconfig).

When you compile, the linker embeds the signature into the boot image. The output binary is what you flash. The ESP32 checks the signature on every boot; if it does not match the burned digest, boot fails.

For OTA updates, the signature check still applies. Each OTA image needs to be signed with the same key (see the esp32-ota-signing tutorial). There is no separate OTA key path; it is the same signing key.

What flash encryption buys you

Secure boot stops tampering. Flash encryption stops reading. With flash encryption enabled, the contents of the flash chip are stored as ciphertext, and the decryption key is also burned into efuse (BLOCK2, separate from the signing key). An attacker who desolders the flash and reads it on a programmer sees encrypted blobs, not your firmware.

The cost: every read of program data has to decrypt on the fly, which is fast on the ESP32 (hardware AES accelerator) but does cost a few CPU cycles per access. For most code it is invisible. For tight inner loops that touch flash constantly, profile.

Flash encryption comes in two flavors:

  • Development mode (release): the encryption key is readable by the firmware, so espefuse.py can re-encrypt new images during development. You can keep flashing over the air without re-burning efuses.
  • Production mode (release): the encryption key is hidden even from the firmware. Once you flash in production mode, the chip will only boot images that were pre-encrypted with that key. Switching from development to production is a one-way efuse burn.

For most products, you develop in development mode, then do one final "production flash" before shipping each unit. The CI build that produces release binaries for shipping should set CONFIG_SECURE_FLASH_ENC_ENABLED=y and CONFIG_SECURE_FLASH_ENCRYPTION_MODE_RELEASE=y.

OT vs non-OT chips (the hardware distinction)

Older ESP32 modules (the original ESP-WROOM-32) have "non-OT" (non-one-time) flash chips. These have a quirk: the flash itself can be reprogrammed in-circuit, which means an attacker with a flash programmer can swap your encrypted image for their own. The chip will still try to decrypt and run it, but it does not have to be your firmware.

Newer "OT" chips (ESP32-WROVER, ESP32-S2, ESP32-S3, ESP32-C3, and later) have one-time-programmable flash that physically cannot be re-written once encrypted. This is what you want for a product.

If you are sourcing modules for a new design, pay attention to the datasheet's flash type. If it says "non-OT," either upgrade the module or accept that flash encryption is a partial defense (still useful, still recommended, but it does not stop a determined attacker with a hot air station).

Production workflow vs development workflow

The workflows are different because efuse burns are permanent.

Development:

  1. Develop normally on a board without secure boot enabled.
  2. Test with the secure boot test board before merging to main.
  3. OTA images get signed; secure boot verifies them on boot.

Production per-unit (the first time):

  1. Flash a fresh, never-used module.
  2. Burn the signing key digest into BLOCK1 (once per module type).
  3. Burn the flash encryption key into BLOCK2 (once per module type).
  4. Burn ABS_DONE_0 (the secure boot "abstract" bit) and FLASH_CRYPT_CNT to the production maximum.
  5. Flash the encrypted + signed firmware.
  6. Verify the board boots and connects to your server.

Per-unit costs after the first one: zero key burning. Just flash the encrypted firmware.

Production for OTA updates:

  1. Sign the new image with the same signing key.
  2. Encrypt the new image with the same flash encryption key.
  3. Push over the OTA channel.
  4. The chip verifies the signature, decrypts the image, writes to the OTA partition, sets the boot flag, and reboots.

"I bricked my board" recovery

There are three failure modes:

  1. Wrong signing key burned, no firmware image is accepted. The chip will not boot. You cannot recover over the serial port because secure boot is doing exactly what it should. The chip has to be replaced.

  2. efuse burned with the right key but the flash is empty or corrupt. The chip will not boot because no signed image exists. Reflash the signed image over serial. The image gets verified, decryption happens, the chip boots. Recoverable.

  3. Flash encryption mode set to production, but the key is mismatched with the image. Same as case 1: chip will not boot and you cannot recover over the wire. Physical replacement.

The lesson: before you burn efuses, make sure the signed firmware image is on the board. The order matters:

  1. Flash the signed firmware.
  2. Verify it boots.
  3. Burn the efuses.

Not the other way around.

When NOT to use secure boot

If any of these apply, skip it:

  • You are building a hobby project that stays on your desk.
  • You are still iterating daily on the firmware and want to be able to flash without re-signing every time.
  • You do not have a backup of the signing key (you will lose the device the first time efuses get burned wrong).
  • Your production run is fewer than 10 units and you are fine accepting the support cost of "anyone can flash this."

Secure boot is for products. For everything else, plain OTA is plenty.

What to build next

  • An OTA pipeline where the firmware image is signed before it goes to the OTA server. See the esp32-ota-signing tutorial.
  • A CI build that runs espsecure.py sign_data on every release artifact and refuses to publish if the signature step fails.
  • A small "secure boot cheat sheet" laminated card for your build station with the efuse order on it. Sounds dumb, saves you at 11pm the first time you forget.

Chapter 66

ESP32: TLS in depth, root CAs, and validating real certificates

esp32 · 30 min

The first time I got an ESP32 to talk to a real HTTPS endpoint, I used wifiClient.setInsecure() because the example did, and the example worked, so I shipped it. Six months later someone pointed out that the "secure" in HTTPS was doing nothing on my board, and that an attacker on the same WiFi could swap the server's certificate for their own and read every payload I sent. That was the day I learned how root CAs actually work on a microcontroller.

This tutorial is what I wish someone had handed me that morning: how to bundle a root CA, how the ESP32 validates a chain, what setInsecure() really means, and the patterns I use now (proper CA bundle, fingerprint pinning, certificate rotation for OTA).

What root CAs are and why they matter

When your browser visits https://example.com, the server hands back a certificate. That certificate is not signed by itself; it is signed by an intermediate CA, and that intermediate is signed by a root CA that your browser already trusts (e.g. ISRG Root X1 for Let's Encrypt).

The chain looks like this:

Root CA (in your trust store)
  -> signs -> Intermediate CA (in the server's cert)
    -> signs -> Leaf certificate (example.com)

The browser walks up the chain until it finds a root it trusts. If the chain breaks anywhere (expired cert, wrong intermediate, untrusted root), the connection fails.

The ESP32 has no built-in trust store. Every HTTPS example you've ever copied either uses setInsecure() (accept anything) or hands the library a single root CA certificate. The right pattern is to bundle the specific CA you need and let WiFiClientSecure do the chain walk.

The setInsecure() pattern (and when it is wrong)

The minimum-viable HTTPS example looks like this:

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  WiFiClientSecure client;
  client.setInsecure();   // <-- the footgun

  HTTPClient http;
  http.begin(client, "https://example.com/api/temperature");
  int code = http.GET();
  Serial.println(code);
  http.end();
}

void loop() {}

setInsecure() tells WiFiClientSecure to skip certificate validation entirely. Any cert from any server is accepted, including a self-signed one an attacker generates on the spot. On your home WiFi this might be fine for a hobby project. The day your device is on a coffee shop network, an airport network, or any network someone else controls, it is not fine.

The acceptable cases for setInsecure() are short: prototyping, hobby builds that never leave your house, and "I need to test if this endpoint even works before I bother with certs." If you are shipping a product, do not ship setInsecure().

The setCACert() pattern (the right way)

The clean fix is to bundle the root CA as a PEM string in your sketch and hand it to the library:

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

// ISRG Root X1 (Let's Encrypt's root). The PEM is the entire thing
// between the BEGIN and END markers, including newlines.
const char* root_ca = R"EOF(
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANbOLggKv+IxTdGNs8/TGFy
0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LI
gAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGW
ynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJw
PmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89T
dexHjKdoKpuSpaoE1opQ5Oj0i3LpkgB2HOaXQ+9O5cQivQ9j5+i6VMWvXlJzr8pxr
Vn0n6h8pUMb8DAw98oB7RGGJKyEXJbuJOiJlvwxJtTsCAwEAAaOB/DCB+TAdBgNVHQ4E
FgQU9Yj02a4XSXN1M7bjWRLKEIxlbnEwgckGA1UdIwSBwTCBvoAU9Yj02a4XSXN1M7bj
WRLKEIxlbnGheKR2MHQxCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1
cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgxghBEAiByVS3t
o6BnLcydXaZWCObfwmCJE3cjB/EyQqfr5rOorJKSGI8NeKYmDnLXItIuZm+o4=
-----END CERTIFICATE-----
)EOF";

WiFiClientSecure client;

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  client.setCACert(root_ca);
  // Optional: also verify the hostname matches the cert (off by default!)
  client.setHandshakeTimeout(30);   // seconds; default is too short

  HTTPClient http;
  http.begin(client, "https://example.com/api/temperature");
  int code = http.GET();
  Serial.println(code);
  http.end();
}

void loop() {}

Three things to notice:

  1. The PEM includes the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- markers. The library needs them.
  2. setCACert() accepts the root you trust. The intermediate that the server sends is validated against this root automatically.
  3. setHandshakeTimeout() defaults to 30 seconds in newer cores but was 5 seconds in older ones. The handshake can take a while on a slow network. Setting it explicitly is a habit worth keeping.

Where do you get the root CA PEM? Open the site in a desktop browser, click the padlock, view the certificate, walk the chain to the root, and export the root as Base64-encoded PEM. Or grab it from the CA's website (e.g. https://letsencrypt.org/certs/isrgrootx1.pem).

Fingerprint pinning (the belt and suspenders)

CA validation is good. Pinning the certificate's SHA-256 fingerprint is better, because if your CA gets compromised (it happens) the attacker still cannot impersonate your server. The pattern is:

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "cert_pin.h"   // const char* expected_pin = "AA:BB:CC:...";

// Use the bundled CA
client.setCACert(root_ca);

// After the handshake, verify the peer cert's fingerprint manually
// (the helper lives in the BearSSL callbacks; see esp32-https-notes
// for the full snippet).

For most projects I stop at setCACert(). For products that ship at scale or that handle credentials (API tokens, user data), I add a fingerprint check on top. The cost is one more line of code in a custom callback; the upside is that the entire CA compromise class of attack goes away.

Common certificate errors and what they mean

When the handshake fails, the ESP32 prints something like one of these. Here is what they actually mean:

  • unable to get local issuer certificate (X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY = 20): the server's cert chain ends in a root your store does not know. Fix: bundle the right root CA.
  • certificate has expired (X509_V_ERR_CERT_HAS_EXPIRED = 10): the clock on the ESP32 is wrong, or the cert is genuinely past its notAfter date. The ESP32 has no RTC by default, so you need an NTP sync or a battery-backed RTC for cert checks to work at all.
  • hostname mismatch (X509_V_ERR_HOSTNAME_MISMATCH = 62): the cert is for api.example.com but you connected to example.com. The ESP32 does not check hostnames by default. Call client.setHandshakeTimeout(30) and verify in your code.
  • self-signed certificate in certificate chain (X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN = 19): the server's chain ends in a self-signed root that you did not trust. Either bundle that root, or the server is misconfigured.
  • certificate verify failed (catch-all): the library refuses to print the actual reason by default. Call client.setHandshakeTimeout(30) and turn on verbose logs to see why (see below).

Time is the silent killer here. An ESP32 that just booted has no idea what year it is. If you have not called configTime() and synced with an NTP server, every cert check fails with "expired" or "not yet valid." Set the time before doing TLS, or use a battery-backed RTC.

Debugging TLS with verbose logs

When the handshake fails and you do not know why, turn on BearSSL verbose logging:

#include "esp_log.h"

WiFiClientSecure client;
client.setCACert(root_ca);

// Enable verbose SSL logging (the call is on the underlying SSL_CTX,
// not on the WiFiClientSecure object directly)
Serial.setDebugOutput(true);   // ESP-IDF verbose logs over the serial port

Then watch the serial output. You will see the exact X509 error code and which step in the chain walk failed. The codes I listed above (starting at X509_V_ERR_*) all show up in this output.

Certificate rotation for OTA

A 90-day Let's Encrypt cert means your device's trust store has to re-validate every quarter. With setCACert(root_ca) and a single root that does not change (ISRG Root X1 has been around for years and Let's Encrypt has committed to keeping it around through 2035), this is not a problem you have to solve. But for self-hosted servers, or for services that rotate intermediates, plan for it.

The pattern I use: bundle the root, not the intermediate. The intermediate can rotate every quarter; the root is the anchor that does not change. When the intermediate does rotate, your code does not need to change.

When to use ESP-IDF TLS instead

The Arduino WiFiClientSecure wraps mbedTLS (older cores) or BearSSL (newer cores). It works, it ships with the IDE, and it is what 95% of projects should use. If you need ECDSA certificates, TLS 1.3, mutual TLS (client certificates), or hardware crypto acceleration that the Arduino wrapper does not expose, drop down to ESP-IDF's esp_tls. That is a 200-line idf.py setup for what Arduino does in five lines. Reach for it only when the wrapper is in the way.

What to build next

  • A simple HTTPS client that posts JSON to your own server with a pinned fingerprint.
  • A mutual-TLS setup where the ESP32 presents a client certificate (covered in the IoT with ESP32 book).
  • An OTA update that downloads signed firmware over HTTPS and verifies it before flashing. The esp32-ota-signing tutorial is the next step.

Chapter 67

ESP32: TCP sockets in depth, the protocol your MQTT rides on

esp32 · 40 min

Every IoT protocol you have ever used (MQTT, HTTP, WebSockets, even raw serial-over-network tools) runs on top of TCP. The "client connects, sends data, gets response, disconnects" pattern is the same shape every time. Most tutorials treat the connection as magic: you call client.connect(), it works, you call client.read(), data appears. The tutorial does not say what happens between those calls.

This tutorial does. It covers what TCP actually is, the three-way handshake that happens before any byte moves, the ESP32 APIs (WiFiClient and WiFiServer) you build on top of it, the gotchas that bite when you forget TCP is a stream not a request-response protocol, and the performance knobs (Nagle, window size, keep-alive) that you should set deliberately rather than leaving at defaults.

What TCP is vs UDP

Two protocols sit on top of IP:

  • TCP: connection-oriented. Two endpoints establish a session (the three-way handshake), exchange data, close cleanly. Delivery is guaranteed (in order, no duplicates). The cost is a few extra round trips for setup and some overhead per packet.
  • UDP: connectionless. The sender just sends a packet; the receiver might never see it. No retransmission, no ordering, no setup. Fast and simple, but no guarantees.

For MQTT, HTTP, file transfer, anything where the data must arrive intact, use TCP. For DNS queries, video streaming, sensor readings where the latest value matters more than every value, use UDP.

On the ESP32 the TCP path is WiFiClient (client role) and WiFiServer (server role). The UDP path is WiFiUDP. Both sit on top of the same lwIP stack the ESP-IDF networking layer provides.

The three-way handshake

Before any byte of actual data moves, the client and server exchange three packets:

Client -> Server: SYN (sequence = X)
Server -> Client: SYN, ACK (sequence = Y, ack = X+1)
Client -> Server: ACK (sequence = X+1, ack = Y+1)

This is the "three-way handshake." It negotiates the initial sequence numbers and confirms that both sides are ready to send and receive. After the third packet, the connection is "established" and data can flow in either direction.

This is what client.connect(host, port) is doing under the hood. The call returns 1 when the handshake completes, 0 when it fails. A typical failure is a TCP RST (the server is not listening on that port) or a timeout (the server is unreachable, or a firewall is silently dropping the SYN).

The handshake takes 1-3 round trips. On a local WiFi network, each round trip is a few milliseconds. On a wide-area network, each round trip is 50-200 ms. That is why "is this server up" checks can feel slow; the handshake alone is half a second across the public internet.

The WiFiClient API

WiFiClient is the TCP client on the ESP32. The basic lifecycle:

#include <WiFi.h>

WiFiClient client;

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Connect to a TCP server on port 1234
  if (client.connect("192.168.1.50", 1234)) {
    Serial.println("Connected");

    // Send a message (note: \n or \r\n is your responsibility)
    client.println("hello from esp32");

    // Wait for a response (with a timeout)
    unsigned long start = millis();
    while (client.available() == 0) {
      if (millis() - start > 5000) {
        Serial.println("Timeout waiting for response");
        client.stop();
        return;
      }
      delay(10);
    }

    // Read the response
    while (client.available()) {
      char c = client.read();
      Serial.print(c);
    }

    // Close the connection
    client.stop();
  } else {
    Serial.println("Connection failed");
  }
}

void loop() {}

The key calls:

  • connect(host, port): initiates the three-way handshake. Returns 1 on success, 0 on failure.
  • connected(): returns true if the connection is still open. The library detects when the remote side closes the connection (the server sends FIN, which is a TCP close packet) and updates this flag.
  • available(): returns the number of bytes buffered locally that have not been read yet. Zero means "no data right now, try later." The TCP stream has no "end of message" marker; you have to define your own protocol on top.
  • read(): reads one byte. Returns -1 if no data is available.
  • write(buf, len) or print() / println(): sends data.
  • stop(): closes the connection. Sends FIN, waits for ACK, frees the local resources.

The WiFiServer API

WiFiServer listens on a port and accepts incoming connections:

#include <WiFi.h>

WiFiServer server(1234);

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);
  server.begin();
  Serial.print("Listening on ");
  Serial.println(WiFi.localIP());
}

void loop() {
  WiFiClient client = server.available();
  if (!client) return;

  while (client.connected()) {
    if (client.available()) {
      char c = client.read();
      Serial.print(c);
      client.write(c);   // echo back
    }
  }
  client.stop();
}

server.available() is non-blocking: returns a WiFiClient if a new connection is pending (or data is available), or an "empty" client (false) otherwise. The pattern of "accept, handle until disconnect, repeat" is how every TCP server works. The ESP32 accepts multiple simultaneous connections up to the lwIP limit (5 default, see below).

When to use TCP vs HTTP vs MQTT

You have three main options for device-to-server communication:

  • Raw TCP (WiFiClient): smallest overhead, you define the protocol. MQTT, custom industrial protocols, anything with its own wire format.
  • HTTP (HTTPClient or WebServer): standard, easy to debug with curl. Adds request/response framing on top of TCP. Good for one-shot operations; worse for high-frequency traffic.
  • MQTT (PubSubClient): persistent connection, pub/sub topics, broker manages fanout. The protocol most home automation stacks already speak.

Most projects end up using a combination: HTTP for occasional operations, MQTT for regular telemetry, raw TCP for custom protocols.

The keep-alive pattern

Each TCP connection has overhead: a TCP control block in memory (about 1 KB on lwIP), a socket in the OS, a slot in the server's connection table. Opening and closing connections is expensive. For high-frequency comms, you keep one connection open and reuse it.

WiFiClient mqttClient;
unsigned long lastConnectAttempt = 0;
const unsigned long reconnectInterval = 5000;

void ensureConnected() {
  if (mqttClient.connected()) return;

  // Don't try to reconnect too often
  if (millis() - lastConnectAttempt < reconnectInterval) return;
  lastConnectAttempt = millis();

  if (mqttClient.connect("192.168.1.50", 1883)) {
    Serial.println("Connected");
  } else {
    Serial.println("Connection failed");
  }
}

void loop() {
  ensureConnected();

  if (mqttClient.connected() && mqttClient.available()) {
    // Handle incoming data
    char c = mqttClient.read();
    // ...
  }
}

The ensureConnected() pattern is the standard. Check the state, reconnect if needed, do not busy-loop on reconnect. The reconnectInterval is there because if the server is down, spamming connect() calls floods the network with SYN packets and makes the outage worse.

Buffer management

The ESP32 has a 4 KB TCP send buffer per connection. When you call client.write(), the data goes into the send buffer; lwIP drains it onto the wire as the receiver ACKs.

If the receiver is slow, the buffer fills. When full, client.write() returns 0 (non-blocking). If the receiver goes silent for the TCP timeout (default 75 seconds on lwIP), the connection is reset. For high-rate data sources, batch into 100-500 byte chunks and call delay(0) between writes.

The "stuck connection" gotcha

This is the bug that eats a day every time someone hits it.

The symptom: client.connected() returns true, client.available() returns 0, and the connection never closes. The remote server might be hung, or might have crashed without sending FIN.

The cause: TCP does not have a heartbeat by default. If the remote side crashes in a way that does not send FIN (power loss, kernel panic, network cable yanked), the local side has no way to know. The connection sits there in the "established" state forever.

The fix: TCP keep-alive. Enable it on the socket, and the local side will send a probe packet after a period of inactivity:

client.connect("192.168.1.50", 1234);
client.setKeepAlive(60,    // idle seconds before first probe
                    10,    // interval between probes
                    3);    // number of probes before declaring dead

After 60 seconds of no activity, the ESP32 sends a probe. If the probe is not ACKed within 10 seconds, it sends another. If 3 probes in a row fail, the connection is declared dead and client.connected() returns false. Your code then triggers a reconnect.

MQTT's keep-alive (the PINGREQ/PINGRESP cycle) is the same pattern at the application layer. For raw TCP you need to enable it yourself.

Performance tuning (TCP_NODELAY, window size)

Two knobs you might want to set explicitly:

TCP_NODELAY (disable Nagle's algorithm):

By default, TCP waits 40 ms after a small write before sending it, hoping more data will arrive that can be coalesced into one packet. This is Nagle's algorithm. It is good for throughput, bad for latency.

For interactive protocols (chat, telnet, MQTT command responses), disable Nagle:

client.connect("192.168.1.50", 1234);
client.setNoDelay(true);   // disable Nagle, send each write immediately

For bulk transfer (file upload, sensor data dump), leave Nagle enabled.

TCP window size:

The TCP window controls how many bytes can be in flight before the sender must wait for an ACK. Larger window = more throughput on high-bandwidth, high-latency links. lwIP's default window is small (about 4 KB) which is fine for LANs and slow WANs but can bottleneck a 10 Mbps satellite link.

For ESP32 specifically, the default window is fine for almost every use case. Do not tune this unless you have profiled and know it is the bottleneck.

Connecting to non-HTTP services (raw TCP)

This is the actual workflow for most IoT: you have a server that is not HTTP. Maybe it is a custom binary protocol, maybe it is a serial-over-TCP bridge, maybe it is a SCADA system.

The pattern is the same as the HTTP example: connect, write your message in the format the server expects, read the response in the format the server returns it. There is no parser library; you write the byte handling yourself.

For binary protocols, use client.write() with explicit byte arrays and client.read() to consume one byte at a time. For text protocols, client.println() and client.readStringUntil('\n') work fine.

The limit on simultaneous connections (5 default)

The lwIP stack on the ESP32 supports up to 5 simultaneous TCP connections by default. A 6th connection (from a new client or from WiFiClient::connect()) is refused or causes the oldest to drop, depending on which side is initiating.

If you need more, edit lwipopts.h:

#define MEMP_NUM_TCP_PCB 10

Each PCB is about 1 KB plus per-connection buffers. For a server that accepts many simultaneous clients (a dashboard with multiple websockets), the 5-connection limit is real. Either run multiple ESP32s, use UDP, or accept the limit.

When something breaks

  • "Connection refused." The server is not listening on that port. Check the server is running. Check the port number. Check any firewall.
  • "Connection times out." The server is unreachable. Check the IP address. Check routing. Check that the ESP32 is on the same network as the server.
  • "Connection drops after a few minutes." TCP keep-alive is probably off and the server or network is killing idle connections. Enable keep-alive, or send application-layer pings, or both.
  • "Data gets corrupted on long messages." You are reading before the entire message has arrived. Buffer until you have the whole thing (a length prefix, a terminator, a fixed size, whatever your protocol defines). TCP is a stream, not a message protocol; you have to frame your messages yourself.

What to build next

  • A simple TCP echo server on your laptop (socat TCP-LISTEN:1234 EXEC:cat) and an ESP32 client that connects, sends "hello," and prints the response.
  • A line-based chat protocol between two ESP32s over your WiFi. Each is both client and server (alternating connection initiation). Adds the reconnect logic.
  • The MQTT tutorial if you have not done it yet. MQTT is just TCP with a defined application-layer protocol; understanding TCP first makes MQTT much clearer.
  • The HTTP server tutorial (esp32-http-server-in-depth) for the request-response pattern over TCP.

Chapter 68

ESP32: WebSocket server, the right protocol for live dashboards

esp32 · 45 min

The first time I tried to push live sensor readings to a browser dashboard, I polled every 200 ms with fetch(). It worked, until I added three more charts. Then the dashboard started missing readings, the ESP32 was spending half its CPU on answering HTTP requests, and the browser was generating thousands of requests per minute for what was, in the end, the same handful of values changing at 5 Hz.

The right tool for that job is WebSocket. The browser opens one connection to the ESP32, the connection stays open, and the ESP32 pushes new data whenever it has new data. No polling, no request overhead, no per-update HTTP setup. This tutorial covers what WebSocket actually is (it is not "HTTP with extras"), how to serve it on the ESP32 with ESPAsyncWebServer, the heartbeat that keeps it alive, and the disconnect-detection gotcha that bites when a phone goes to sleep and wakes up an hour later.

What WebSocket is vs HTTP

HTTP is a request-response protocol. The client asks, the server answers, the connection closes (or stays open with keep-alive, but the model is still "client asks, server answers"). The server cannot push data the client did not ask for.

WebSocket fixes that. The client and server both upgrade an HTTP connection into a persistent, bidirectional channel. After the upgrade, either side can send a message at any time. The server can push. The client can push. There is no request-response shape anymore.

The thing most people get wrong: WebSocket is not "HTTP with push." It is a separate framing protocol that rides on top of a TCP connection that started as HTTP. The wire format is different, the semantics are different, the headers you send are different.

The upgrade handshake

WebSocket starts as a normal HTTP request, with a special header that asks the server to upgrade the connection:

GET /ws HTTP/1.1
Host: 192.168.1.42
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server agrees, it sends back:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The 101 Switching Protocols status is the magic number. After that, both sides switch to the WebSocket framing protocol. The HTTP request/response machinery is done.

In practice you do not write this handshake yourself. The ESPAsyncWebServer library handles it; you register a handler on a path and the library does the upgrade dance. But knowing what is happening on the wire is the difference between "my WebSocket broke and I have no idea why" and "oh, the upgrade header is wrong."

The WebServer / ESPAsyncWebServer pattern

The sync WebServer library does not support WebSocket well. You want ESPAsyncWebServer. It is the same library from the HTTP server tutorial, with AsyncWebSocket added on top:

#include <WiFi.h>
#include <ESPAsyncWebServer.h>

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

void onWsEvent(AsyncWebSocket *server,
               AsyncWebSocketClient *client,
               AwsEventType type,
               void *arg,
               uint8_t *data,
               size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("Client %u connected\n", client->id());
      break;
    case WS_EVT_DISCONNECT:
      Serial.printf("Client %u disconnected\n", client->id());
      break;
    case WS_EVT_DATA:
      // Handle incoming data
      break;
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  ws.onEvent(onWsEvent);
  server.addHandler(&ws);
  server.begin();
}

Install via Sketch >> Include Library >> Manage Libraries >> search ESPAsync WebServer by me-no-dev. You also need AsyncTCP (the underlying TCP library) installed alongside it.

The frame format

A WebSocket message is one or more "frames." Each frame has a small header (2-14 bytes depending on flags) followed by the payload. The bits you care about:

  • Opcode (4 bits): text frame (1), binary frame (2), close (8), ping (9), pong (10). Text and binary are your data. Close is "I am done." Ping and pong are heartbeats.
  • MASK (1 bit): set on frames from client to server. The server must unmask before reading. The library does this for you.
  • Payload length: 7 bits for small frames, 16 bits for medium, 64 bits for large. The wire format is the same as HTTP chunked transfer encoding.

You almost never deal with this directly. client->text("hello") or client->binary(buffer, len) on the ESP32 side, and event.data on the browser side. The library handles framing.

The ping/pong heartbeat

TCP keep-alive (covered in the TCP sockets tutorial) eventually detects dead connections, but the timeout is long (60 seconds by default). WebSocket has a faster heartbeat: ping/pong.

The server (or client) sends a ping frame. The other side must respond with a pong frame within a timeout. If no pong arrives, the connection is declared dead.

ESPAsyncWebServer does this automatically. Every 30 seconds (by default) the library sends a ping; if no pong comes back, it fires a WS_EVT_DISCONNECT event and removes the client. You do not have to write any of this.

The gotcha: if your handler is blocking (e.g. reading a sensor over slow I2C), the ping might not get sent in time, and the library thinks the connection died. The async pattern is the fix.

Handling multiple clients

AsyncWebSocket keeps a list of connected clients. To broadcast to all of them:

ws.textAll("hello everyone");

To send to one client:

client->text("just for you");

To iterate and do something with each:

for (AsyncWebSocketClient *c : ws.getClients()) {
  if (c->status() == WS_CONNECTED) {
    c->text("ping");
  }
}

The default max is 4 concurrent clients, set by #define WS_MAX_QUEUED_MESSAGES and the socket count. For a dashboard with 1-3 browser tabs open, the default is fine. For a public dashboard that might have 20 viewers, bump the limit in the library or accept the cap.

The broadcast pattern (the actual use case)

The typical IoT use case: ESP32 reads a sensor every 100 ms, broadcasts the reading to all connected browsers. The full loop:

unsigned long lastBroadcast = 0;

void loop() {
  // AsyncWebServer does its work in the background; no
  // explicit handleClient() call.

  if (millis() - lastBroadcast > 100) {
    lastBroadcast = millis();
    float reading = readSensor();   // your sensor read

    char buf[64];
    snprintf(buf, sizeof(buf), "{\"value\":%.2f}", reading);
    ws.textAll(buf);
  }
}

That's the whole pattern. Open the page in a browser, see live updates without polling. The browser side is a 10-line WebSocket JavaScript snippet (covered in the dashboard tutorial, if you want the full client code).

Binary vs text frames

Two payload types:

  • Text: UTF-8 string. Use for JSON, plain text, anything string-shaped.
  • Binary: raw bytes. Use for compact numeric data, binary protocols, anything that is not a string.

For most IoT, text JSON is fine. The overhead is small (a few bytes per message) and debugging with mosquitto_sub-equivalent browser dev tools is trivial. For high-rate sensor streams (1 kHz+), binary is worth it. A 4-byte float as binary is 4 bytes; as JSON it is 8-15 bytes.

On the ESP32, client->text("...") and client->binary(buf, len) set the opcode. On the browser side, event.data is either a string or an ArrayBuffer depending on which was sent.

The disconnect-detection gotcha

The most common bug: a phone connects, then the screen turns off and the Wi-Fi goes into low-power mode. The TCP connection silently breaks. The phone never sends a close frame. The ESP32 thinks the client is still there.

The fix is the ping/pong heartbeat. With the default 30-second ping interval, the ESP32 will detect the dead client within a minute and fire WS_EVT_DISCONNECT. The client's slot is freed and the next broadcast skips it.

If your dashboard has 10 phones that all go to sleep at night, you can hit the 4-client limit even when "nobody is connected." The fix is either raise the limit, or send an explicit close when the phone tells the page to unload (pagehide event in the browser).

When to use WebSocket vs polling

Use WebSocket when:

  • The server has data to push (sensor readings, alerts, status changes)
  • The update rate is faster than 1 Hz (polling at 5+ Hz starts to feel laggy and burns CPU on both ends)
  • The dashboard has more than one panel that needs the same live data (one connection feeds all of them)

Use polling (regular HTTP fetch) when:

  • The update rate is 1 Hz or slower
  • The dashboard is simple (one chart, one value)
  • The connection might be flaky (Wi-Fi reconnects, phone sleeping) and you want the request to fail loudly rather than silently

MQTT over WebSocket is the third option for when the dashboard sits behind a broker that already speaks MQTT. The MQTT tutorial covers that path.

What to build next

  • A live dashboard with three charts, all fed by one WebSocket connection. The browser code is a 20-line JavaScript snippet with new WebSocket("ws://192.168.1.42/ws") and an onmessage handler that updates the chart.
  • A bidirectional control panel: dashboard sends {"led": "on"} over the WebSocket, ESP32 toggles a GPIO and sends back {"led": "on", "ack": true}. The same connection, both directions.
  • A multi-client chat between two ESP32s over your local network. Each ESP32 is a WebSocket client to a small Node.js relay. Demonstrates the broadcast pattern at the relay layer.
  • The HTTP server tutorial (esp32-http-server-in-depth) for the request-response pattern, then compare it to this push pattern. Most projects need both.

Chapter 69

ESP32: measure ambient light with the BH1750 lux sensor

esp32 · 20 min

The BH1750 is the light sensor I reach for when I want an answer, not a ratio. It reads actual lux (the unit your phone's screen brightness uses) over I2C. Two wires, done. The LDR photoresistor everyone starts with gives you a raw ADC number that changes with every board and every resistor you pair it with. The BH1750 gives you a number you can compare across projects.

This tutorial gets you a working lux meter in about 20 minutes.

What you need

  • ESP32 dev board
  • BH1750 breakout board (the GY-302 module is the common one, about $3)
  • 4 jumper wires

The BH1750 is the right pick over an LDR when you care about the reading (e.g. automating blinds, checking if a room is bright enough for plants). It is calibrated, it does not drift, and it uses I2C so you do not burn an ADC pin. An LDR is still fine for "is it dark enough to turn the light on" projects where nobody sees the number.

Wiring (I2C)

BH1750 ESP32
VCC 3.3V
GND GND
SCL GPIO 22
SDA GPIO 21
ADDR GND (or leave floating)

The ADDR pin selects the I2C address. Floating or tied to GND puts the sensor at 0x23. Tie it to 3.3V and it moves to 0x5C (e.g. useful when you want two BH1750s on the same bus).

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "BH1750", install the one by Christopher Laws.

The code

#include <Wire.h>
#include <BH1750.h>

BH1750 lightMeter;

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);   // SDA, SCL

  if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
    Serial.println("BH1750 ready");
  } else {
    Serial.println("BH1750 not found, check wiring");
    while (1) delay(1000);
  }
}

void loop() {
  float lux = lightMeter.readLightLevel();
  Serial.print("Light: ");
  Serial.print(lux);
  Serial.println(" lx");
  delay(1000);
}

Upload, open Serial Monitor at 115200. Cover the sensor with your hand and you should watch the number drop to near zero. Point it at a lamp and it climbs into the hundreds.

What the numbers mean

Lux readings only make sense with reference points (e.g. a dark room is under 10 lux, an office is around 300-500, direct sunlight is over 10,000):

Reading What it means
0-10 lx Dark, moonlight or a dark room
10-100 lx Dim indoor lighting
100-500 lx Normal indoor lighting
500-1000 lx Bright task lighting
1000-10000 lx Overcast day, or near a bright window
10000+ lx Direct sun

The resolution modes

The library defaults to one-shot high-res mode, which gives you 1 lux resolution with a 120 ms measurement time. If you are battery powered and want speed, CONTINUOUS_HIGH_RES_MODE_2 trades resolution (0.5 lux) for half the measurement time. For a plant monitor or a blind controller, the default is right and you should not think about it again.

The trap I hit: in continuous mode the sensor reads constantly and the first reading after a mode change is stale. Read twice and throw the first one away, or just accept a one-reading lag at startup.

What you learned

  • The BH1750 gives calibrated lux over I2C, no calibration step needed.
  • ADDR pin selects between two I2C addresses.
  • The pattern: init the sensor in setup(), read in loop(), done.

When something breaks

  • "BH1750 not found": run an I2C scanner first. If the address is 0x23 or 0x5C, wiring is fine and the begin() call is wrong. If you see nothing at all, SDA and SCL are swapped.
  • Readings stuck at -1 or 0: the sensor is in one-shot mode and you are reading too fast. Add a delay or switch to continuous mode.
  • Readings jump around wildly: your jumper wires are too long for the I2C bus. Shorten them or slow the bus clock down.

What to build next

  • Pair this with the relay module tutorial and you have a light that turns itself on at dusk.
  • The plant monitor project uses soil moisture, but the BH1750 tells you whether the plant is actually getting sun.
  • The book IoT with ESP32 bundles this with the other sensor tutorials.

Chapter 70

ESP32: add precision analog inputs with the ADS1115

esp32 · 30 min

The ESP32's built-in ADC is the weakest part of the chip. It is 12-bit, nonlinear at both ends of the range, and noisy enough that consecutive readings of the same voltage can differ by 50 counts. For "is the soil dry" that is fine. For a load cell, a thermocouple, or any sensor where you care about small voltage changes, it is not.

The ADS1115 is the fix. It is a 16-bit, 4-channel ADC that talks I2C for about $4. It has a programmable gain amplifier, true differential inputs, and it is stable enough to read a thermocouple through an amplifier without the reading wandering.

What you need

  • ESP32 dev board
  • ADS1115 breakout (the Adafruit one has a nice terminal block; the GY-ADS1115 clone works identically, about $4)
  • A sensor to test with (a potentiometer is fine for proving it works)
  • Jumper wires

Wiring (I2C)

ADS1115 ESP32
VDD 3.3V
GND GND
SCL GPIO 22
SDA GPIO 21
ADDR GND (address 0x48)
A0-A3 Your analog signals

The ADDR pin picks one of four I2C addresses (0x48, 0x49, 0x4A, 0x4B for GND, VDD, SDA, SCL respectively). That means up to four ADS1115s on one bus, 16 analog inputs total.

The ADS1115 runs on 3.3V here, so its full-scale input tracks the supply voltage. Do not feed it 5V signals unless you power it from 5V and use a level shifter on I2C. Staying at 3.3V keeps everything safe for the ESP32.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "Adafruit ADS1X15", install the Adafruit one.

The code

#include <Wire.h>
#include <Adafruit_ADS1X15.h>

Adafruit_ADS1115 ads;

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);

  if (!ads.begin(0x48)) {
    Serial.println("ADS1115 not found, check wiring");
    while (1) delay(1000);
  }

  // Gain sets the full-scale range:
  //   GAIN_TWOTHIRDS -> +/-6.144V (default)
  //   GAIN_ONE       -> +/-4.096V
  //   GAIN_FOUR      -> +/-1.024V
  //   GAIN_SIXTEEN   -> +/-0.256V
  ads.setGain(GAIN_ONE);
}

void loop() {
  int16_t raw = ads.readADC_SingleEnded(0);
  float volts = ads.computeVolts(raw);

  Serial.print("Raw: ");
  Serial.print(raw);
  Serial.print("  Volts: ");
  Serial.println(volts, 4);
  delay(500);
}

Upload, open Serial Monitor. Turn the potentiometer and watch four decimal places move. Compare that to the ESP32's own ADC and you will see the difference immediately: the ADS1115 holds steady, the internal ADC wanders.

Differential mode, the real reason to buy this

The single-ended read above is the basic use. The feature that earns the $4 is differential mode, where the chip reads the voltage BETWEEN two pins instead of between a pin and ground:

int16_t diff = ads.readADC_Differential_0_1();
float volts = ads.computeVolts(diff);

This is how load cells and thermocouples work: the sensor outputs a tiny voltage difference between two wires (e.g. a load cell might output 10 mV at full weight), and common-mode noise (anything pushing both wires the same direction) cancels out. The ESP32 ADC cannot do this at all. If your project involves a Wheatstone bridge or a shunt resistor, differential mode is the whole reason.

The gain setting

The gain sets the input range. Pick the smallest range that fits your signal:

Gain Full-scale Use when
GAIN_TWOTHIRDS +/-6.144V 5V signals
GAIN_ONE +/-4.096V Most 3.3V sensors
GAIN_FOUR +/-1.024V Thermocouple amps, shunt resistors
GAIN_SIXTEEN +/-0.256V Load cells, microvolt signals

Wrong gain does two different bad things. Too small a range clips (readings pin at the max), too large a range wastes resolution. With a load cell amp that outputs 0-20 mV, GAIN_SIXTEEN is the difference between 200 usable counts and 3200.

The data rate tradeoff

The ADS1115 samples at 8 or 128 samples per second. The default in the library is 128. For slow sensors (temperature, weight, light) that is plenty. If you set ads.setDataRate(RATE_ADS1115_8SPS) you get more internal averaging and cleaner readings at the cost of speed (e.g. right choice for a scale).

What you learned

  • The ADS1115 adds 16-bit, low-noise analog inputs over I2C.
  • Differential mode reads the voltage between two pins, which cancels noise and is required for bridge sensors.
  • Gain and data rate trade range against resolution and noise.

When something breaks

  • Readings pegged at 32767 or -32768: your input exceeds the gain range. Drop the gain (bigger +/- range) or scale the signal down.
  • "ADS1115 not found": run an I2C scanner. Four possible addresses depending on the ADDR pin. The Adafruit library begin() takes the address as its argument.
  • Readings noisy even on the ADS: your sensor wire is long and picking up mains hum. Average 8 readings, or check that the sensor and ESP32 share a ground.
  • Slow loop: at 8 SPS each read blocks for 125 ms. That is the chip doing its job, not your code. Raise the data rate or accept it.

What to build next

  • The load cell + HX711 tutorial covers a dedicated weight amp, but the ADS1115 in differential mode handles bridge sensors too.
  • Pair with a thermocouple amp for high-temp logging with real resolution.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 71

ESP32: environmental sensing with the BME680

esp32 · 25 min

The BME680 is what you get when Bosch takes the BME280 and adds a gas sensor. It reads temperature, humidity, barometric pressure, and volatile organic compounds (VOCs, e.g. the stuff from cooking, paint, and breathing). One chip, one I2C bus, four readings. It is the sensor in most of the commercial indoor air quality gadgets, and the breakout costs about $8.

This tutorial gets you all four readings on screen in about 25 minutes.

What you need

  • ESP32 dev board
  • BME680 breakout (the Adafruit one is the reference design; the purple GY-BME680 clones work the same, about $10)
  • 4 jumper wires

Why the BME680 over the BME280 you may already have: the gas resistance channel. It is not a CO2 meter (be suspicious of any sub-$30 "CO2 sensor"), but it reacts to cooking, solvents, and a full room of people. The BME280 is still the right pick if you only want weather data. The 680 is the pick when "is the air in this room actually stale" is the question.

Wiring (I2C)

BME680 ESP32
VCC 3.3V
GND GND
SCL GPIO 22
SDA GPIO 21
SDO GND (or floating)
CS 3.3V (forces I2C mode)

The CS pin on some breakouts needs to be high to select I2C instead of SPI. The Adafruit board ties it for you. The purple clones usually want it tied to VCC.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "BME68x", install the Bosch one (by BOSCHSensortec). The BSEC library gives you a computed IAQ (indoor air quality) index, but it is closed-source and license-encumbered (e.g. fine for personal use, check before shipping a product). This tutorial uses the plain open-source driver and raw gas resistance.

The code

#include <Wire.h>
#include "bme68x.h"
#include "bme68x_defs.h"
// Simpler: use the Adafruit BME680 library
#include <Adafruit_BME680.h>

#define SEALEVELPRESSURE_HPA (1013.25)

Adafruit_BME680 bme;

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);

  if (!bme.begin(0x76)) {
    Serial.println("BME680 not found, check wiring and CS/SDO pins");
    while (1) delay(1000);
  }

  bme.setTemperatureOversampling(BME68X_OS_16X);
  bme.setHumidityOversampling(BME68X_OS_2X);
  bme.setPressureOversampling(BME68X_OS_16X);
  bme.setIIRFilterSize(BME68X_IIR_FILTER_SIZE_3);
  bme.setGasHeater(320, 150);   // 320C for 150 ms
}

void loop() {
  unsigned long endTime = bme.beginReading();
  if (endTime == 0) {
    Serial.println("reading failed");
    return;
  }
  delay(endTime - millis());   // sensor needs this long to measure

  if (!bme.endReading()) {
    Serial.println("failed to complete reading");
    return;
  }

  Serial.print("Temp: ");   Serial.print(bme.temperature / 100.0);  Serial.println(" C");
  Serial.print("Hum:  ");   Serial.print(bme.humidity / 1000.0);   Serial.println(" %");
  Serial.print("Pres: ");   Serial.print(bme.pressure / 100.0);     Serial.println(" hPa");
  Serial.print("Gas:  ");   Serial.print(bme.gas_resistance / 1000.0); Serial.println(" KOhms");
  Serial.println();
  delay(5000);
}

Upload, open Serial Monitor at 115200. Wave a marker with the cap off near the sensor and watch the gas resistance drop.

Reading the gas number

Gas resistance runs the counterintuitive way: LOWER ohms means MORE gas. A clean room settles somewhere around 30-50 KOhms after the sensor has been running for a while. Wave a marker at it and it can drop below 5 KOhms. The number is relative: what matters is the baseline your own room settles at (e.g. compare today's reading to last hour's reading, not to an absolute number).

The heater runs hot and draws real current (about 5 mA average, more in bursts). Fine on USB power. On battery, this is the sensor that eats your budget; duty-cycle it (one reading a minute) or use deep sleep between readings.

The burn-in

Bosch says the gas sensor needs 48 hours of powered operation before the baseline is stable. In practice it drifts downward over the first day and then stabilizes. Do not calibrate anything on day one. This is the same 24-hour warm-up dance the MQ sensors do, just tamer.

What you learned

  • The BME680 is a BME280 plus a VOC-reactive gas resistance channel.
  • Gas resistance reads inversely: low ohms, more gas. Baseline is per room, so track relative changes.
  • The two-stage read pattern: beginReading() schedules, delay to endTime, then endReading() collects.

When something breaks

  • "BME680 not found": the CS/SDO pin matters on clones. Tie CS high (3.3V) to select I2C. Run an I2C scanner; the address is 0x76 or 0x77 depending on the SDO pin.
  • Gas resistance stuck near 0: the heater is not firing. Check setGasHeater() was called with sane values (320 degrees, 150 ms).
  • Humidity reads high all the time: you just showered or the sensor is near a plant. It is probably right. Bosch's humidity element is fast, faster than your nose.

What to build next

  • The air quality monitor project combines this with an OLED to build a standalone room monitor.
  • Push the gas reading to MQTT and graph it over a week to find your room's baseline.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 72

ESP32: build a digital scale with a load cell and HX711

esp32 · 30 min

A load cell is a strain gauge in a metal frame: put weight on it, the frame flexes a few microns, and the resistance of a tiny bridge circuit changes by a few millivolts. Those millivolts are way below what any microcontroller ADC can read directly. The HX711 exists to amplify and digitize exactly that signal, 24 bits at a time.

This tutorial builds a working scale with 1-gram resolution on an ESP32, including the calibration step everyone skips and then regrets.

What you need

  • ESP32 dev board
  • Load cell: the 5 kg "bar" type (four wire, white/red/black/green) is the standard starter one, about $8. Pick the capacity for your use (e.g. 1 kg for a kitchen scale, 20 kg for a pet feeder, 100 kg for a beehive)
  • HX711 breakout board (the little green one with two big screw terminals, about $2)
  • A flat rigid plate and 4 spacers (the load cell mounts in the middle and takes force on its ends; mounting hardware matters more than the sensor)

Wiring

HX711 Connects to
E+ Load cell RED wire
E- Load cell BLACK wire
A- Load cell WHITE wire
A+ Load cell GREEN or BLUE wire
VCC ESP32 3.3V
GND ESP32 GND
DT (DOUT) ESP32 GPIO 16
SCK ESP32 GPIO 4

The load cell wires are the four screws on the left side of the HX711. Colors vary by manufacturer, which is the number one source of "my scale reads negative" confusion. The wiring above is the standard 5 kg bar from SparkFun and most AliExpress sellers.

Load cells are direction-sensitive. The arrow stamped on the metal points the way force should flow (top to bottom). Mount it upside down and your readings will be negative or nonlinear. If it reads backwards after correct wiring, just negate in software, the cell does not care.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "HX711", install the one by bogde (bogdan).

The code

#include "HX711.h"

#define LOADCELL_DOUT_PIN 16
#define LOADCELL_SCK_PIN  4

HX711 scale;

void setup() {
  Serial.begin(115200);
  scale.begin(LOADCELL_DOUT_PIN, LOADCELL_SCK_PIN);

  Serial.println("Wait for scale to stabilize, remove any weight.");
  delay(2000);
  scale.tare();   // zero the scale, current reading = 0
  Serial.println("Tared. Place a known weight.");

  // Calibration factor: 228.0f is a common starting point for the
  // 5kg bar cell. You MUST calibrate for your own cell (see below).
  scale.set_scale(228.0f);
}

void loop() {
  if (scale.is_ready()) {
    float grams = scale.get_units(10);   // average 10 readings
    Serial.print("Weight: ");
    Serial.print(grams, 1);
    Serial.println(" g");
  } else {
    Serial.println("HX711 not ready");
  }
  delay(500);
}

Calibration, the part everyone skips

The 228.0f in the code is a starting point, not a value. Every load cell + HX711 pair has a different gain. Calibrate in two steps:

  1. Tare with nothing on the scale (already done by tare() above).
  2. Place a known weight (e.g. a bag of sugar marked 1000 g, or a roll of nickels, which is 200 g in the US and 250 g in Canada, or literally any kitchen item you trust). Note the raw reading:
// Add this to loop() temporarily:
Serial.println(scale.read_average(20));   // raw counts

Divide: calibration_factor = raw_reading / known_grams. Put that number in set_scale(). Done. My scale needed 426.7, yours will need something different, and that is not a bug.

The mechanical setup

The load cell only works if it is mounted right. The bar type has mounting holes on both ends; one end attaches to your base, the other to the platform where the weight goes, and the force has to flow through the cell's arrow direction. Direct screw-to-tabletop reads nothing useful (e.g. the cell needs room to flex).

The standard pattern: two plates, the cell sandwiched between them on spacers, force applied to the top plate. 3D-printed brackets exist for every cell size; search "load cell mount 5kg" on any print site.

The 80-bit trap

The HX711 outputs a 24-bit reading per sample, but the actual useful resolution is set by the load cell's rated output (typically 1 mV/V). At 5 kg with a 3.3V excitation you get about 3.3 mV full scale, which the HX711's 128x gain turns into about 420 mV. Spread across 2^24 counts, that is theoretical noise-floor resolution of milligrams. Real world: 0.1 g is achievable with the 10-sample average, 1 g is solid without averaging. Do not expect more from a single cell.

What you learned

  • Load cells output millivolts; the HX711 amplifies and digitizes them over a two-wire serial link.
  • Tare() zeroes the scale, set_scale() converts raw counts to units, and the factor is unique to your hardware.
  • Resolution is a function of the cell's mV/V rating, not the ADC bits.

When something breaks

  • Readings negative when weight applied: your white/green wires are swapped, or the cell is mounted upside down relative to the arrow. Negate the calibration factor and move on.
  • "HX711 not ready" forever: the DT and SCK wires are swapped, or you are powering the HX711 from 5V and reading with 3.3V logic. The HX711 wants VCC of 2.7-5.5V but its DOUT swings to VCC, so power it from 3.3V.
  • Readings drift upward over minutes: temperature. Load cells have real thermal drift. Tare() at startup, and if the project runs long, re-tare on a schedule or on a button.
  • Jumping by grams every read: the scale is on a wobbly surface, or your wiring has a bad contact. Average more samples (get_units(20)) or fix the contact first.

What to build next

  • The ADS1115 tutorial covers the general precision-ADC path (the HX711 is the specialized cheap version of the same idea).
  • A pet feeder project: this scale + the servo tutorial + a timer.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 73

ESP32: send email alerts over SMTP, no cloud service

esp32 · 35 min

Email is the notification channel that never dies. Push services come and go, apps get rewritten, but any email address you own still works in 20 years. An ESP32 can send email directly over SMTP (the protocol every mail server speaks), including through your own mail server or any provider that offers SMTP. No middleman service, no API key from a startup.

This tutorial sends a plain alert email with STARTTLS in about 35 minutes, with a self-hosted angle for each step.

What you need

  • ESP32 dev board with Wi-Fi
  • An SMTP account. Three routes, in the order I would try:
    1. Your own mail server (e.g. the one your domain already runs)
    2. A self-hosted relay on your LAN (MailHog for testing, or a Postfix relay on your Raspberry Pi)
    3. Your existing email provider's SMTP (e.g. Gmail needs an "app password" now, not your real password)

The SMTP conversation, in plain English

SMTP is a 1980s protocol, all text. Your ESP32 says HELO, the server says hello back, the ESP32 asks to upgrade the connection to TLS (STARTTLS), and only then does the login and mail content flow. The library handles the dance; knowing the shape helps when it breaks.

Step Who Says
1 ESP32 Connect TCP port 587
2 Server "220 ready"
3 ESP32 EHLO + STARTTLS
4 Both TLS handshake, everything after is encrypted
5 ESP32 AUTH LOGIN with base64 user/pass
6 ESP32 MAIL FROM, RCPT TO, DATA
7 ESP32 The message text, then a line with a single "."

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "ESP32 MailClient", install the one by Mobizt (the library is "ESP_Mail_Client"). It handles the TLS handshake and the MIME formatting, which is the part you really do not want to hand-roll.

The code

#include <WiFi.h>
#include <ESP_Mail_Client.h>

#define SMTP_HOST "mail.yourdomain.com"
#define SMTP_PORT 587
#define SMTP_AUTHOR_EMAIL "alerts@yourdomain.com"
#define SMTP_AUTHOR_PASSWORD "your-app-password"
#define TO_EMAIL "you@yourdomain.com"

SMTPSession smtp;
Session_Config config;

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  config.server.host_name = SMTP_HOST;
  config.server.port = SMTP_PORT;
  config.login.email = SMTP_AUTHOR_EMAIL;
  config.login.password = SMTP_AUTHOR_PASSWORD;
  config.login.user_domain = "yourdomain.local";

  sendAlert("ESP32 booted", "The plant monitor is online.");
}

bool sendAlert(String subject, String body) {
  config.secure.mode = sec_modes::sec_starttls;   // port 587 way

  SMTP_Message message;
  message.sender.name = "ESP32 Alerts";
  message.sender.email = SMTP_AUTHOR_EMAIL;
  message.subject = subject;
  message.addRecipient("Brian", TO_EMAIL);
  message.text.content = body;
  message.text.charSet = "utf-8";

  if (!smtp.connect(&config)) {
    Serial.print("Connect failed: ");
    Serial.println(smtp.errorReason());
    return false;
  }
  if (!MailClient.sendMail(&smtp, &message)) {
    Serial.print("Send failed: ");
    Serial.println(smtp.errorReason());
    return false;
  }
  Serial.println("Mail sent");
  return true;
}

void loop() {
  // e.g. alert when a sensor crosses a threshold
  delay(60000);
}

The self-hosted test rig

Before you fight a real provider's TLS rules, test against a fake SMTP server on your LAN. MailHog (single Go binary, runs on a Raspberry Pi in 2 minutes) accepts anything you throw at it and shows it in a web inbox:

# On the Pi:
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

Point the ESP32 at 192.168.1.50, port 1025, no TLS, no auth. You get instant feedback on whether your ESP32 code is right, separate from whether your mail provider is having a day. Then flip to the real server by changing four constants.

Port 465 vs 587 is the classic confusion. 587 is submission with STARTTLS (encryption upgrades after plaintext hello). 465 is SMTPS, TLS from byte one. The library's secure.mode must match: use sec_starttls for 587 and sec_ssl for 465. Wrong pairing gives you a timeout with zero useful information.

App passwords, not your real password

Any major provider now wants an "app password" for non-browser clients (e.g. Google Account >> Security >> 2-Step Verification >> App passwords). That password goes in SMTP_AUTHOR_PASSWORD. It can be revoked independently, which is exactly what you want for a device sitting in your garage.

If you self-host mail: create a dedicated alerts@ account with send-only rights if your server supports it. One device, one account, one revocation switch.

What you learned

  • SMTP with STARTTLS on port 587 is the portable pattern; the library handles the handshake and MIME.
  • A local MailHog relay decouples "is my ESP32 code right" from "is my mail provider right".
  • App passwords scope the credential to the device.

When something breaks

  • Timeout on connect: you paired port 587 with sec_ssl or port 465 with sec_starttls. The pairing of port and TLS mode is the single most common failure.
  • "Authentication failed": Gmail and others reject the account password. It wants the app password. Also check the account does not require a browser sign-in first (e.g. Google blocks "less secure" sign-ins from new regions until you have used the app password once from a real client).
  • "530 Must issue a STARTTLS command first": you are speaking plaintext to a server that demands TLS. That is the library's secure mode again.
  • Works on USB power, dies on battery: the TLS handshake takes real time and current (e.g. 3-5 seconds at 150 mA). Budget for it in your deep-sleep power math, or move alerts to ntfy (lighter).
  • Mail goes to spam: your domain's SPF/DKIM do not cover the sending server. That is a DNS problem, not an ESP32 problem. Add the sending server to your SPF record.

What to build next

  • The ntfy tutorial is the lighter-weight notification channel (push instead of email); run both, email for the weekly digest.
  • The MQTT tutorial is the transport for high-frequency sensor data; email is for the 1-in-a-thousand events.
  • The book IoT with ESP32 bundles the connectivity tutorials.

Chapter 74

ESP32-CAM: take and send a photo on motion via ntfy

esp32 · 40 min

A security camera that does the right thing: stays silent until something moves, then puts the photo on your phone. This version sends the image through ntfy (the self-hosted push service), so the whole path is yours: camera to server to phone, nothing leaves your network unless you want it to.

This is the project version of the streaming tutorial. Same board, but instead of watching a live stream, you get a photo pushed to you when it matters.

What you need

  • ESP32-CAM board (AI-Thinker, about $10)
  • HC-SR501 PIR motion sensor (about $2)
  • FTDI adapter (for programming, as always with this board)
  • The ntfy app on your phone, subscribed to a topic (e.g. "driveway-cam-k4p9") per the ntfy tutorial

Wiring

PIR ESP32-CAM
VCC 5V
GND GND
OUT GPIO 13

GPIO 13 is free on the ESP32-CAM (it is not part of the camera or SD bus). The HC-SR501's output is 3.3V logic even on 5V supply, so no level shifter needed.

Two PIR adjustments worth making with the little pot screwdrivers:

Pot Setting
Sensitivity (left) Middle to start; clockwise = more range
Time delay Fully counterclockwise (minimum hold time, ~3 s)

The HC-SR501 has a ~3 second retrigger lockout after each trigger (e.g. a cat walking by produces one notification, not forty).

The code

#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>

// AI-Thinker pin map (same as the streaming tutorial)
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y2_GPIO_NUM     5
#define Y3_GPIO_NUM    18
#define Y4_GPIO_NUM    19
#define Y5_GPIO_NUM    21
#define Y6_GPIO_NUM    36
#define Y7_GPIO_NUM    39
#define Y8_GPIO_NUM    34
#define Y9_GPIO_NUM    35
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

#define PIR_PIN 13

const char* NTFY_TOPIC = "https://ntfy.sh/driveway-cam-k4p9";
// Self-hosted: http://192.168.1.50:25800/driveway-cam-k4p9

bool cameraReady = false;

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);

  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_VGA;
  config.jpeg_quality = 12;
  config.fb_count = 1;

  cameraReady = (esp_camera_init(&config) == ESP_OK);
  Serial.println(cameraReady ? "camera ok" : "camera FAILED (check power)");

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nready");
}

void loop() {
  if (cameraReady && digitalRead(PIR_PIN) == HIGH) {
    sendPhoto("Motion in the driveway");
    delay(10000);   // cool-down: one photo per event, not per wave
  }
  delay(200);
}

void sendNtfy(String title, String message) {
  WiFiClient wc;
  HTTPClient http;
  http.begin(wc, NTFY_TOPIC);
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("X-Title", title);
  http.POST(message);
  http.end();
}

void sendPhotoNtfy() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return;

  WiFiClient wc;
  HTTPClient http;
  http.begin(wc, NTFY_TOPIC);
  http.addHeader("Content-Type", "image/jpeg");
  http.addHeader("X-Title", "Motion detected");
  // ntfy accepts a raw binary body as the attachment
  http.POST(String((const char*)fb->buf), fb->len);
  http.end();
  esp_camera_fb_return(fb);
}

void sendPhotoNtfy() { /* see above */ }

(For the real build, keep sendNtfy for text alerts and sendPhotoNtfy for the image; the multipart form version of the attachment is in the library docs, the raw-body version above works against both ntfy.sh and a self-hosted server.)

The PIR trigger physics

PIR sensors read moving infrared (warm bodies against the background). They false-trigger on: HVAC vents blowing warm air, direct sun through a window, and anything moving fast across their field of view (e.g. the classic "car headlights sweeping the wall" trigger). Mount looking down from chest height and mask the sensor's edge zones with electrical tape if it covers a window.

The Wi-Fi reconnect trap

The photo send can take 2-4 seconds of Wi-Fi. If the PIR fires while Wi-Fi is down (router rebooted overnight), the POST fails silently. The production pattern checks connectivity before trusting an alert:

bool wifiOk() {
  if (WiFi.status() == WL_CONNECTED) return true;
  WiFi.disconnect();
  WiFi.reconnect();
  delay(3000);
  return WiFi.status() == WL_CONNECTED;
}

Alerts during an outage are lost; a heartbeat message every hour ("still alive, no motion") tells you the device itself is alive vs broken (e.g. distinguish "no motion" from "camera fell off the Wi-Fi").

What you learned

  • PIR into a GPIO that has a spare pin (GPIO 13 here) is the whole input side.
  • ntfy accepts a raw JPEG body; the phone shows it as an attachment.
  • The cool-down delay after a trigger is what keeps one event from becoming fifty notifications.

When something breaks

  • PIR never fires: HC-SR501 has a warm-up of its own (60 s or so of instability after power-up). Also check the jumper on the PIR: H (retrigger) is what you want, not L.
  • Photos but no notification: the POST return code again. 200 is delivered. A 429 is rate-limiting; self-host if you are triggering often.
  • Photo arrives but is garbage pixels: the board's power supply again, or jpeg_quality set below 10 (which over-compresses).
  • Works inside, dies outside: Wi-Fi out of range of the driveway. The ESP32-CAM antenna is a PCB trace, not a hero. Add the external antenna connector version of the board for real range.

What to build next

  • The trail camera project is this plus battery + solar + SD storage for the field version.
  • The streaming tutorial is the live-view sibling.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 75

ESP32: record audio with an INMP441 I2S microphone

esp32 · 35 min

The ESP32 has an I2S peripheral built in, and the INMP441 is the microphone that takes advantage of it: a MEMS mic that outputs digital audio directly, no amplifier stage, no noise floor from a long analog wire. Together they record real 16-bit audio for about $4 in parts.

This is the hardware half of every "ESP32 hears something" project: sound detection, clap triggers, voice notes to a server, wake words.

What you need

  • ESP32 dev board
  • INMP441 MEMS microphone module (the little purple board, about $3)
  • 6 jumper wires

Why the INMP441 over an analog mic module (e.g. the MAX9814 boards): the analog route is simpler to wire but every centimeter of wire picks up noise, and the ESP32's ADC is the weak part of the chip. I2S is digital end to end: the mic does the analog work centimeters from the capsule and ships clean bits.

Wiring (I2S)

INMP441 ESP32
VDD 3.3V
GND GND
SD (data) GPIO 32
WS (word select) GPIO 25
SCK (clock) GPIO 33
L/R GND (left channel)

The L/R pin selects which stereo half the mic answers on. Ground it for left. (Two mics on one bus is how stereo recording works: one with L/R grounded, one to VDD.)

The code

#include <driver/i2s.h>

#define I2S_WS   25
#define I2S_SD   32
#define I2S_SCK  33
#define SAMPLE_RATE 16000
#define SAMPLE_BUF 256

void setup() {
  Serial.begin(115200);

  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 64,
    .use_apll = false
  };

  i2s_pin_config_t pins = {
    .bck_io_num = I2S_SCK,
    .ws_io_num = I2S_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_SD
  };

  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pins);
  Serial.println("I2S mic ready");
}

void loop() {
  int32_t raw[SAMPLE_BUF];
  size_t bytesRead;

  i2s_read(I2S_NUM_0, raw, sizeof(raw), &bytesRead, portMAX_DELAY);
  int samples = bytesRead / sizeof(int32_t);

  // Simple loudness: peak absolute sample this buffer
  int32_t peak = 0;
  for (int i = 0; i < samples; i++) {
    int32_t v = raw[i] >> 14;   // INMP441 is 24-bit in a 32-bit frame; shift to sane range
    if (v < 0) v = -v;
    if (v > peak) peak = v;
  }

  Serial.println(peak);
}

Upload, open Serial Monitor. Quiet room prints low hundreds; clap next to the mic and the peak jumps into the tens of thousands. You have working audio.

Getting real audio out (WAV)

The peak meter proves capture works. For actual recording, buffer to PSRAM and write a WAV (e.g. header plus the raw PCM):

// WAV header = 44 bytes of structure + your samples
// Sample rate 16000, 16-bit mono, then write the int32 buffer
// (converted down: sample >> 11 gives 16-bit range)

The full pattern: capture N seconds into PSRAM, sample >> 11 each into a 16-bit array, write a 44-byte WAV header with your rate and length, then ship it over HTTP POST to any server (e.g. upload to the Pi on your LAN, or push the file with the ntfy attachment pattern).

The loudness meter, done right

Peak is a crude meter (a single click spikes it). For "is someone talking in this room" you want RMS (square, average, square-root), which ignores single-sample spikes and tracks actual energy:

uint64_t sum = 0;
for (int i = 0; i < samples; i++) {
  int32_t v = raw[i] >> 14;
  sum += (int64_t)v * v;
}
float rms = sqrt((float)(sum / samples));

Threshold a smoothed RMS (e.g. moving average of 10 buffers) and you have a sound-activated anything, with about one false trigger a day instead of ten.

What you learned

  • I2S is the digital audio bus; the INMP441 is the cheap clean mic for it.
  • The driver install + set_pin pattern is the whole setup.
  • Peak vs RMS: peak for clicks, RMS for "is there sound".

When something breaks

  • All zeros forever: L/R pin floating (tie it), or SD/WS swapped. The I2S RX will happily read silence forever with wrong wiring.
  • Noise floor huge and constant: the mic is picking up the power rail. Use a short 3.3V supply straight from the dev board, not a breadboard rail shared with LEDs.
  • Reading half what you expect: the 32-bit samples are 24-bit values left-aligned; the shift constant controls your range. There is no "wrong" here, just be consistent across captures.
  • Works, then stops after minutes: you are not calling i2s_read() fast enough and the DMA buffers are full. Read in a tight loop or use a FreeRTOS task for audio.

What to build next

  • The wake word tutorial runs a keyword model on exactly this capture path.
  • A clap-triggered light: RMS threshold + the relay tutorial.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 76

ESP32-CAM: live MJPEG streaming web server in the browser

esp32 · 40 min

The ESP32-CAM is a $10 board with a real camera sensor on it, and the single most useful thing you can do with it is put live video in a browser. MJPEG (Motion JPEG) is the format that makes it work with zero client software: the server sends an endless HTTP response, each chunk a JPEG frame, and the browser renders them in an tag (e.g. the same trick every network IP camera uses).

This tutorial builds a working stream you can watch from your phone in about 40 minutes.

What you need

  • ESP32-CAM board (the AI-Thinker model, about $10, camera included)
  • FTDI USB-serial adapter (the ESP32-CAM has no USB port; you need this to program it, about $2)
  • microSD card (optional, for the storage features later)
  • 4 female-female jumper wires
  • A 5V supply that can deliver at least 500 mA (a phone charger works; the camera brownouts on weak power and this is the number one "camera init failed" cause)

Programming setup (the FTDI dance)

The ESP32-CAM exposes serial on GPIO 1/3, not USB:

FTDI ESP32-CAM
5V 5V
GND GND
TX U0R (GPIO 3)
RX U0T (GPIO 1)
(n/a) GPIO 0 to GND during power-up (download mode)

The GPIO 0-to-GND jumper is the step everyone misses. Ground GPIO 0, power the board, then hit upload. After upload, remove the jumper and press reset. If the upload fails with "Failed to connect", the board was not in download mode.

The code

#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>

// AI-Thinker pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y9_GPIO_NUM    35
#define Y8_GPIO_NUM    34
#define Y7_GPIO_NUM    39
#define Y6_GPIO_NUM    36
#define Y5_GPIO_NUM    21
#define Y4_GPIO_NUM    19
#define Y3_GPIO_NUM    18
#define Y2_GPIO_NUM     5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

WebServer server(80);

void setup() {
  Serial.begin(115200);

  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y3_GPIO_NUM;  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_VGA;     // 640x480, the reliable one
  config.jpeg_quality = 12;              // 10-12 is the sweet spot
  config.fb_count = 1;

  if (esp_camera_init(&config) != ESP_OK) {
    Serial.println("Camera init failed (power supply, almost always)");
    delay(3000);
    ESP.restart();
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.print("\nStream at: http://");
  Serial.println(WiFi.localIP());

  server.on("/", handleStream);
  server.begin();
}

void handleStream() {
  WiFiClient client = server.client();
  String response = "HTTP/1.1 200 OK\r\n"
    "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
  server.sendContent(response);

  while (client.connected()) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) continue;

    server.sendContent("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
                       + String(fb->len) + "\r\n\r\n", "text/plain");
    client.write(fb->buf, fb->len);
    server.sendContent("\r\n");
    esp_camera_fb_return(fb);
    delay(80);   // ~12 fps ceiling; lower = fewer dropouts
  }
}

void loop() {
  server.handleClient();
}

Upload, note the IP from Serial Monitor, open http://that-ip/ in any browser on the same network. Live video, one client at a time.

The resolution ladder

frame_size Pixels What it is for
FRAMESIZE_QQVGA 160x120 Machine vision, fast
FRAMESIZE_VGA 640x480 The reliable default
FRAMESIZE_SVGA 800x600 Needs good Wi-Fi
FRAMESIZE_UXGA 1600x1200 Still photos, too slow for streaming

Start at VGA. People jump straight to UXGA, get 2 fps and dropped frames, and conclude the board is junk. It is not: the ESP32's RAM holds exactly one frame buffer, and big frames choke the Wi-Fi send loop (e.g. quality 10 at UXGA is 300 KB per frame; at 12 fps that is 3.6 MB/s sustained, which is most of what the radio has).

The frame buffer trick

fb_count = 1 is the conservative setting. Setting it to 2 gives the camera a double buffer: one frame being captured while the other is being sent. On boards with PSRAM (most ESP32-CAMs have 4 MB), raising it and lowering grab_mode latency gives noticeably smoother video. Try config.fb_count = 2 after the basic version works.

One client, and why

This server handles exactly one streaming client at a time (the while loop holds the connection). A second browser tab will stall the first. That is fine for a driveway camera you glance at. For multi-viewer you would put the stream behind a reverse proxy that fans out, or serve snapshots instead of a stream (e.g. one JPEG refreshed every 500 ms is 90% of the utility for a front-door camera).

What you learned

  • MJPEG is an endless multipart HTTP response; any browser renders it.
  • Frame size, JPEG quality, and fps trade against Wi-Fi bandwidth.
  • Camera init failures are power problems before they are code problems.

When something breaks

  • "Camera init failed 0x20001": power supply. A weak 3.3V rail or thin USB cable brownouts the OV2640. Use a real 5V/500mA+ supply and a short thick USB cable.
  • Stream connects then freezes: Wi-Fi congestion, or you raised the frame size too far. Drop to VGA and delay(100) between frames.
  • Brownout detector in serial output: same root cause as camera init, the supply again. The ESP32-CAM is honest about being hungry.
  • "Failed to connect" on upload: GPIO 0 was not grounded at power-up. Power cycle with the jumper in place.

What to build next

  • The photo-on-motion tutorial adds PIR triggering and sends the image to your phone via ntfy.
  • The trail camera project is the battery-powered version.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 77

ESP32-CAM: pan and tilt mount with two servos

esp32 · 45 min

A fixed camera watches a fixed spot. A pan/tilt camera watches everything in its hemisphere, and the parts cost $12: two SG90 servos and a 3D-printed (or popsicle-stick) bracket. This tutorial adds pan/tilt to the ESP32-CAM streaming setup and drives it from buttons in the same web page.

What you need

  • ESP32-CAM (AI-Thinker) with the streaming tutorial already working
  • 2x SG90 micro servos (about $3 each)
  • A pan/tilt bracket (printed, or the $4 acrylic kit)
  • External 5V supply for the servos (the ESP32-CAM's regulator cannot run two servos plus the camera; this is the trap)
  • A 1000 uF capacitor across the servo supply

The pin problem on the ESP32-CAM

The ESP32-CAM is stingy with free GPIOs. The camera and SD bus eat most of them. The two pins that remain safe for servos:

Servo Signal Notes
Pan (horizontal) GPIO 12 also strap pin, low at boot is fine
Tilt (vertical) GPIO 2 used for SD, fine if no card

GPIO 16 is free too but is wired to the PSRAM on some revisions, so GPIO 12/2 are the standard picks. Servo power comes from the external 5V rail, not the board (e.g. a stalled servo pulls 650 mA and browns out the camera instantly).

Wiring

Servo Wire Connects to
Pan signal (orange) GPIO 12
Pan power (red) External 5V +
Pan ground (brown) External 5V GND and ESP32 GND
Tilt signal (orange) GPIO 2
Tilt power (red) External 5V +
Tilt ground (brown) External 5V GND and ESP32 GND

Common ground between servo supply and ESP32 is what makes the signal meaningful. The 1000 uF capacitor goes across the external 5V and GND rails, close to the servos.

The code

#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>

#define PAN_PIN 12
#define TILT_PIN 2

Servo panServo, tiltServo;
int panAngle = 90, tiltAngle = 90;

WebServer server(80);

// AI-Thinker pin map (same as the streaming tutorial)
// ... camera_config_t identical, see the streaming tutorial ...

void setup() {
  Serial.begin(115200);
  ESP32PWM::allocateTimer(0); ESP32PWM::allocateTimer(1);

  panServo.setPeriodHertz(50);
  tiltServo.setPeriodHertz(50);
  panServo.attach(PAN_PIN, 500, 2400);    // SG90 pulse range
  tiltServo.attach(TILT_PIN, 500, 2400);
  panServo.write(panAngle);
  tiltServo.write(tiltAngle);

  // camera init + wifi + server.on("/") for the stream, as before
  // ...
  server.on("/move", handleMove);
  server.begin();
}

void handleMove() {
  // /move?axis=pan&delta=-10
  int delta = server.arg("delta").toInt();
  if (server.arg("axis") == "pan") {
    panAngle = constrain(panAngle + delta, 0, 180);
    panServo.write(panAngle);
  } else {
    tiltAngle = constrain(tiltAngle + delta, 30, 150);   // limit tilt range
  }
  server.send(200, "application/json",
              "{\"pan\":" + String(panAngle) + ",\"tilt\":" + String(tiltAngle) + "}");
}

void loop() { server.handleClient(); }

The browser side: the stream page plus two rows of buttons that fetch /move?axis=pan&delta=-10 (and +10), with a step of 5 or 10 degrees per press. Small steps beat sliders: each click is an HTTP round trip, and a slider fights the latency.

The jitter problem

SG90s jitter (hum and twitch) for two reasons:

  1. Power. Servos on the camera's 5V rail twitch the camera. The external supply with the big capacitor is the fix, not optional.
  2. Detached servos. After the last move, servo.detach() stops the pulse train and the servo goes quiet until the next command. Add detach 5 seconds after each move if the humming annoys you.

The bracket

The pan servo bolts to the base, its horn up. The tilt servo bolts to a bracket on the pan horn, camera on the tilt horn. Prints are everywhere (search "ESP32-CAM pan tilt"), but the honest version is a $2 acrylic pan/tilt kit from any electronics store, which takes 10 minutes to assemble and holds the camera with one screw.

What you learned

  • GPIO 12 and 2 are the free pins on the ESP32-CAM for servos.
  • Servo power is a separate rail; the capacitor absorbs the stall surge.
  • Step-based web control beats sliders when every move is a round trip.

When something breaks

  • Camera reboots when a servo moves: power rail. External supply, common ground, capacitor. This is the whole section above.
  • Servo jitters at rest: pulse noise from the long signal wire, or detach it when idle.
  • Camera points sideways at boot: the servo's 90-degree center is wherever the horn was when you attached it. Set both servos to 90 in software, pop the horn, re-seat it level, screw it back.
  • Tilt sags under camera weight: SG90 plastic gears vs a camera plus wires. A MG90S (metal gear) tilt servo is the $2 upgrade.

What to build next

  • The streaming tutorial is the video half of this build.
  • The QR code tutorial turns the aimed camera into a scanner.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 78

ESP32: read RFID tags with the RC522

esp32 · 30 min

The MFRC522 is the $3 RFID reader behind most DIY door projects. Tap a card or keyfob, it reports the card's UID over SPI, and your code decides what that UID means. This tutorial gets clean reads on an ESP32 and builds the tap-to-unlock pattern around it.

What you need

  • ESP32 dev board
  • MFRC522 RFID module with card + key fob (the blue PCB with the spiral antenna, about $3)
  • Jumper wires
  • (For the unlock build) a relay module

The RC522 reads MIFARE Classic 13.56 MHz tags: the white card and keyfob that ship with it, plus most transit cards, office badges, and modern hotel cards. It does NOT read the 125 kHz tags (e.g. older entry fobs) or phone-hosted NFC in most cases.

Wiring (SPI)

RC522 ESP32
3.3V 3.3V
GND GND
RST GPIO 22*
SDA (SS) GPIO 21*
MOSI GPIO 23
MISO GPIO 19
SCK GPIO 18
IRQ not connected

*The library default pins vary; these two are what the popular ESP32 examples use, and both are declared in the sketch so any pair of spare GPIOs works. 3.3V only: the module is not 5V tolerant on its logic pins.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "MFRC522", install the one by GithubDeveloper/miguelbalboa (the one with thousands of examples in it).

The code

#include <SPI.h>
#include <MFRC522.h>

#define SS_PIN 21
#define RST_PIN 22

MFRC522 rfid(SS_PIN, RST_PIN);

// Your card UIDs go here (dumped by the reader sketch on first run)
const String VALID_UIDS[] = {"A1 B2 C3 D4", "12 34 56 78"};
const int NUM_UIDS = 2;

void setup() {
  Serial.begin(115200);
  SPI.begin();
  rfid.PCD_Init();
  Serial.println("RFID ready, tap a card");
}

void loop() {
  if (!rfid.PICC_IsNewCardPresent()) return;
  if (!rfid.PICC_ReadCardSerial()) return;

  String uid = "";
  for (byte i = 0; i < rfid.uid.size; i++) {
    uid += (rfid.uid.uidByte[i] < 0x10 ? "0" : "") + String(rfid.uid.uidByte[i], HEX) + " ";
  }
  uid.trim();
  uid.toUpperCase();

  Serial.print("Card: ");
  Serial.println(uid);

  if (is_valid(uid)) {
    Serial.println("GRANTED");
    // relay / strike / ntfy notification here
  } else {
    Serial.println("DENIED");
  }
  rfid.PICC_HaltA();
  rfid.PCD_StopCrypto1();
}

bool is_valid(String uid) {
  for (int i = 0; i < NUM_UIDS; i++) {
    if (VALID_UIDS[i] == uid) return true;
  }
  return false;
}

First run: tap each card, copy the printed UID into VALID_UIDS, re- upload. That is the enrollment.

UID auth vs sector auth

Reading the UID is the tutorial above. The RC522 can also authenticate against a MIFARE sector and read/write data (e.g. storing a secret on the card). The distinction matters for anything beyond a garage door:

Scheme What proves access Cheap attack
UID match The card's serial number Cloneable with a $15 writer
Sector secret Data encrypted on the card Much harder

UID checking is fine for the workshop fridge. For a house door, write a random secret to a card sector and check THAT, not the UID.

The tap-to-unlock build

GPIO 13 to a relay module (the relay tutorial has the wiring), relay to the strike. The unlock pattern with timing:

const int STRIKE_PIN = 13;
unsigned long strikeUntil = 0;

void loop() {
  // ... card read as above ...
  if (is_valid(uid)) {
    strikeUntil = millis() + 3000;   // 3 s unlock window
    digitalWrite(STRIKE_PIN, HIGH);
  }
  if (strikeUntil && millis() > strikeUntil) {
    digitalWrite(STRIKE_PIN, LOW);
    strikeUntil = 0;
  }
}

Millis-based unlock windows keep the reader responsive while the door is open (e.g. a second card can still be read and logged during the window).

What you learned

  • RC522 over SPI: init, IsNewCardPresent/ReadCardSerial, UID as hex.
  • UID enrollment is copy-the-printed-value; sector auth is the upgrade for real security.
  • Millis-based unlock windows, not delay().

When something breaks

  • Always fails to read: 3.3V only. The module is 5V-kill on its logic and half-dead on 5V supply with 3.3V logic. Check voltage first, wiring second.
  • Reads once then never again: missing PICC_HaltA()/StopCrypto1() after a read. The code above has them; when they are gone the reader thinks the same card is still present forever.
  • Intermittent reads at distance: RC522 is a 2-4 cm reader. People tap from 10 cm and blame the code. Mount the reader behind a plastic surface (not metal) and tap on it.
  • UID prints but auth denies: hex formatting. UIDs with leading zeros ("0A") print differently between sketches. Normalize to the padded uppercase format above on both the enrollment and check paths.

What to build next

  • The QR scanner tutorial is the phone-based sibling.
  • The relay tutorial is the output side of the unlock build.
  • The ntfy tutorial pushes a "workshop opened" event with the UID.

Chapter 79

ESP32: hosted MQTT brokers vs self-hosting

esp32 · 30 min

Last month I hit HiveMQ's connection limit on a Tuesday afternoon because I left a test loop running with a 100 ms publish interval. The broker did exactly what free tiers do: it stopped accepting connections and my dashboard went blank until the counter reset. Nothing broke. But the lesson stuck: when your home automation depends on somebody else's free tier, you are renting your infrastructure from a company that will eventually change the pricing page.

The trap here is that the comparison looks obvious in the wrong direction. Hosted brokers win the first hour (no server, no config, TLS included). Self-hosting wins every hour after that, because the broker stops being a dependency on someone else's decisions. This post is the honest version of that comparison, with the same ESP32 sketch pointed at both.

What you need

Needed

  • ESP32 dev board (e.g. ESP32-DevKitC, about $8).
  • A Raspberry Pi (Pi 3B+ or newer, any model with Ethernet or Wi-Fi). This is the self-hosting half. If you already run one for Pi-hole or Home Assistant, you are done; it has room for a broker too.
  • MicroSD card for the Pi (16 GB is plenty).
  • 2 jumper wires, female-female.

Nice to have

  • Soldering iron + solder, if your ESP32 board ships with a bare header to attach.
  • Soldering iron stand, for a safe place to park the hot iron.
  • Helping hands, to hold the header straight while soldering.
  • Anti-static wristband, when handling bare modules on a dry winter day.
  • Wire stripper, for clean wire ends.

Wiring

Pin Connect to
ESP32 3V3 (no wiring needed for MQTT; this is a network tutorial)
USB cable ESP32 to your computer for flashing and Serial output

The only hardware in this tutorial is the ESP32 itself. MQTT is a network protocol, so the "wiring" is TCP port 1883 (or 8883 with TLS). If you want a physical project to publish from, grab the wiring from the DHT22 tutorial and publish that temperature.

Install

On the Raspberry Pi (Mosquitto)

sudo apt update
sudo apt install -y mosquitto mosquitto-clients

Then edit the config:

sudo nano /etc/mosquitto/conf.d/local.conf

and add:

listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd

Create the password file and restart:

sudo mosquitto_passwd -c /etc/mosquitto/passwd brian
sudo systemctl restart mosquitto

Test it from another terminal:

mosquitto_sub -h <pi-ip-address> -u brian -P yourpassword -t "test/#"
mosquitto_pub -h <pi-ip-address> -u brian -P yourpassword -t "test/hello" -m "hi"

If the sub terminal prints hi, your broker works. That took maybe ten minutes, and none of it was a pricing page.

In the Arduino IDE

Arduino IDE >> Tools >> Board >> esp32 >> install if you have not already. Then Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search "PubSubClient" >> install Nick O'Leary's PubSubClient.

The comparison table you actually want

EMQX Cloud serverless HiveMQ Cloud free Mosquitto on a Pi
Cost Free tier, then per-message Free tier, 100 connections Free, runs on hardware you own
Connections on free tier Small free tier, then paid 100 devices Bounded by the Pi (hundreds easily)
Data retention Configurable, then paid Messages not stored on free tier As long as your SD card lives
TLS Included Included, with SNI Yes, with a cert and more config
Uptime guarantee None on free tier None on free tier None, but you control restarts
Data leaves your house Yes Yes No
Dies when You hit the free tier cap You hit the cap or they change terms Your SD card dies (back it up)
Real failure mode Silent throttling, quota alerts Connection refusals at the cap Power blips, fixable with a watchdog

I am not going to pretend the hosted tiers are bad. They are good at what they do (e.g. a HiveMQ free tier behind a demo you show a client, or an EMQX serverless cluster for a project whose hardware lives in three countries). The dishonest version of this comparison pretends free-tier limits do not exist. The other dishonest version pretends a Pi in a closet is enterprise-grade. Both are true at the same time, which is why the recommendation below has a condition attached.

The code

The same sketch works against all three brokers. Only the constants change. This publishes a heartbeat every 10 seconds:

#include <WiFi.h>
#include <PubSubClient.h>

// ---- Pick ONE broker config ----

// A) Mosquitto on your Pi (self-hosted, default recommendation)
const char* WIFI_SSID   = "your-wifi";
const char* WIFI_PASS   = "your-password";
const char* MQTT_HOST   = "192.168.1.50";  // the Pi's IP
const int   MQTT_PORT   = 1883;
const char* MQTT_USER   = "brian";
const char* MQTT_PASS   = "yourpassword";

// B) HiveMQ Cloud free tier
// const char* MQTT_HOST = "abcd1234efgh.s1.eu.hivemq.cloud";
// const int   MQTT_PORT = 8883;
// const char* MQTT_USER = "your-hivemq-user";
// const char* MQTT_PASS = "your-hivemq-pass";
// (keep TLS on; HiveMQ Cloud requires it on 8883)

// C) EMQX serverless
// const char* MQTT_HOST = "xxxx.ala.cn-hangzhou.emqxsl.cn";
// const int   MQTT_PORT = 8883;
// (same TLS notes as HiveMQ)

WiFiClientSecure tlsClient;   // for 8883
// WiFiClient plainClient;    // for 1883 against your own broker
PubSubClient mqtt(tlsClient);

unsigned long lastPublish = 0;

void connectWiFi() {
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("WiFi OK, IP: ");
  Serial.println(WiFi.localIP());
}

void connectMQTT() {
  while (!mqtt.connected()) {
    String clientId = "esp32-" + String((uint32_t)ESP.getEfuseMac(), HEX);
    bool ok = mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS);
    if (ok) {
      Serial.println("MQTT connected");
    } else {
      Serial.print("failed, rc=");
      Serial.print(mqtt.state());
      Serial.println(" retrying in 2s");
      delay(2000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  // For a quick test we skip CA verification. For anything real, pin
  // the broker's CA cert (see the TLS in depth tutorial).
  tlsClient.setInsecure();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setBufferSize(512);
  connectWiFi();
  connectMQTT();
}

void loop() {
  if (!mqtt.connected()) connectMQTT();
  mqtt.loop();

  if (millis() - lastPublish > 10000) {
    lastPublish = millis();
    char payload[64];
    snprintf(payload, sizeof(payload),
             "{\"heap\":%u,\"rssi\":%d}", ESP.getFreeHeap(), WiFi.RSSI());
    mqtt.publish("home/esp32/heartbeat", payload);
    Serial.print("published: ");
    Serial.println(payload);
  }
}

For option A, swap WiFiClientSecure tlsClient for WiFiClient plainClient and drop the setInsecure() line. Port 1883 on your own LAN is fine; the Pi never talks to the internet for this.

For options B and C you are on 8883 with TLS, and setInsecure() skips certificate verification. That is fine for a weekend test and wrong for anything you actually care about (the TLS in depth tutorial shows the cert-pinning version).

Which one should you actually run

Default to Mosquitto on the Pi. Here is the reasoning, honestly stated:

  1. Your data stays in your house. Whole-house energy data, door events, presence: that is a profile of your life. I do not hand that to a free tier.
  2. No quotas. A 100 ms publish loop will not get you throttled.
  3. No terms-of-service risk. Cloud free tiers change. A Pi does not read its own pricing page.
  4. It composes with everything else you self-host (Node-RED, InfluxDB, Home Assistant all eat MQTT from the same broker).

When hosted is the right call: the broker must be reachable from multiple locations you do not control, you cannot punch a hole through the network (campus Wi-Fi, corporate NAT), or you genuinely want zero servers. In those cases HiveMQ Cloud's free tier is the one I would pick (100 connections, TLS enforced, decent dashboard). EMQX serverless is the better fit when you might grow into their paid tier, because the migration is a config change instead of a replatform.

One more honest note: self-hosting means you are the ops team. If the Pi reboots, your broker is down until it comes back. Add sudo systemctl enable mosquitto so it starts on boot, put the Pi on a small UPS if the power blips matter, and put a watchdog on the ESP32 side so it reconnects (the reconnect logic above already does).

What you learned

  • MQTT brokers are swappable infrastructure: the same sketch runs against Mosquitto, HiveMQ Cloud, or EMQX serverless with only constants changed.
  • Hosted free tiers trade control for convenience, and the trade goes badly exactly when your project starts working.
  • The self-hosted default is Mosquitto on a Pi: free, local, no quotas, and it slots into the rest of your home server stack.
  • Port 1883 is plain MQTT; 8883 is MQTT over TLS. Cloud brokers force TLS; your own broker can too when it faces the internet.

When something breaks

  • rc=-2 on connect (option A). The broker is not reachable. Ping the Pi. Then check sudo systemctl status mosquitto and make sure the listener config actually saved. The most common cause is editing /etc/mosquitto/mosquitto.conf instead of a file in conf.d/.
  • rc=-2 on connect (options B/C). You forgot TLS. Cloud brokers on 8883 need WiFiClientSecure, not WiFiClient. If you see a certificate error with setInsecure() removed, that is the missing CA cert, not a wrong password.
  • Connection works, publishes vanish. The default PubSubClient buffer is 256 bytes and some cloud brokers require larger packets. Call mqtt.setBufferSize(512) before setServer().
  • Password auth fails on your own Mosquitto. You created the password file but forgot to restart the service, or allow_anonymous is still true with no password_file line. Restart, then test with mosquitto_pub from a laptop before blaming the ESP32.
  • Works on the bench, dies on your Wi-Fi. Some routers isolate wireless clients (AP isolation). The ESP32 cannot see the Pi. Disable client isolation for your IoT network or move both onto 2.4 GHz.

What to build next

  • The MQTT publish-subscribe tutorial is the protocol-level follow up: topics, QoS, retained messages, and last-will.
  • The ntfy notifications tutorial pushes a message to your phone from the same ESP32 when a threshold trips (e.g. publish to MQTT and fire an ntfy alert from the same loop).
  • The Raspberry Pi Mosquitto tutorial goes deeper on the broker side: bridging, websockets, and keeping the broker alive across reboots.
  • The ADS1115 external ADC tutorial pairs with this for real sensor data worth publishing.

Chapter 80

ESP32: measure whole-house AC current with an SCT-013

esp32 · 35 min

The first time I clamped an SCT-013 around the main feed in my panel, I got a flat zero. Not "small numbers," flat zero, because I had bought the 30A version with the built-in burden resistor and then fed its output into an ESP32 ADC pin with no bias network. The sensor was fine. My reference point was missing: an AC current transformer swings around zero, and the ESP32 ADC only reads positive voltages. Until you lift the signal up to mid-rail, the negative half of every AC cycle is invisible to the chip.

The trap here is that there are two kinds of SCT-013 and only one of them plugs into an ESP32 without extra parts (e.g. the SCT-013-000 has no burden resistor and needs one; the SCT-013-030 has a 34 ohm burden built in and puts out 1V per 30A). This post walks through both, builds the bias network, and ends with a watts readout you can trust within about 5%.

Safety, once, in bold: you are working near mains electricity. The SCT-013 is a clamp and never touches bare metal, so the sensor side is safe. The panel side is not. Do not remove panel covers. Clamp around the outside insulation of one conductor, and if you are not comfortable inside a breaker panel, hire an electrician for the ten minutes of work. This is the one place in this hobby where the "it is just 120V" attitude kills people.

What you need

Needed

  • ESP32 dev board (e.g. ESP32-DevKitC, about $8).
  • SCT-013 current transformer, split-core clamp. Two usable variants:
    • SCT-013-030 (30A, built-in burden, 1V output at rated current), about $12. Pick this one if you want the simplest wiring.
    • SCT-013-000 (100A, no burden, bare secondary), about $11. Pick this if your main feed can exceed 30A or you want to size your own output range.
  • Burden resistor, only for the SCT-013-000: 33 ohm, 1/4W (puts 3.3V peak across it at 100A, which safely saturates before your ADC clips; see the math section).
  • 2 resistors for the bias divider: 10K ohm each (or 2x 4.7K and one pot if you want exact mid-rail).
  • 1 capacitor, 10 uF electrolytic (bias filter).
  • Breadboard + 6 jumper wires.
  • Multimeter, to sanity-check the divider before you trust it.

Nice to have

  • Soldering iron + solder, if you hardwire the bias network instead of breadboarding it.
  • Soldering iron stand, for parking the iron between joints.
  • Helping hands, to hold the resistor leads while soldering.
  • Anti-static wristband, for handling the bare ESP32 module.
  • Magnifying goggles, for reading the resistor color bands (33R vs 330R is an easy misread at 1 a.m.).
  • Soldering mat, to keep solder blobs off your desk.
  • Wire stripper, for clean leads on the resistor network.

Wiring

The bias network lifts the AC signal to mid-rail (1.65V on a 3.3V ESP32):

SCT-013 / network Connect to
SCT-013 tip (red) Burden resistor top (SCT-013-000 only) AND 10K divider top AND ADC input
SCT-013 sleeve (black) Burden resistor bottom (SCT-013-000 only) AND 10K divider bottom AND capacitor negative
Divider midpoint ESP32 GPIO 34 (ADC1)
Capacitor + Divider midpoint (same node as GPIO 34)
Capacitor - ESP32 GND
Divider bottom ESP32 GND
Divider top ESP32 3V3

Concretely: the two 10K resistors in series sit between 3V3 and GND, and their midpoint is your new "zero current" reference at 1.65V. The SCT-013 output rides on top of that reference. The 10 uF cap stabilizes the midpoint against ADC sampling noise.

3V3 ----[10K]----+----[10K]---- GND
                 |
                 +---- GPIO 34 (with 10uF to GND)
                 |
              SCT-013 output (one lead)
                 |
        (other lead to GND for -030 variant)

With the SCT-013-030 (built-in burden), the secondary is just a signal source: connect one lead to the bias node and the other to GND. With the SCT-013-000, the burden resistor IS the load across the two secondary leads, and the same two leads also connect to the bias network. Do not double-burden the -030; adding a 33R across a sensor that already has one inside will halve your output.

The clamp goes around ONE conductor (hot or neutral, not both). Around both, the magnetic fields cancel and you read zero forever. This is the single most common "my CT reads nothing" cause, and no amount of code fixes it.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search "EmonLib" >> install (OpenEnergyMonitor). EmonLib does the RMS math and the power factor calculation properly, so you do not have to hand-roll sampling. The raw version below is included anyway because understanding the math is half the point.

The code

ESP32 (Arduino) with EmonLib

#include "EmonLib.h"   // OpenEnergyMonitor EmonLib

EnergyMonitor emon1;

// SCT-013-030: 1V RMS at 30A. With the built-in burden treated as the
// load and the ADC reference at 3.3V, the classic OpenEnergyMonitor
// calibration constant for this combination is 30.
// For the SCT-013-000, compute it: CT ratio / burden ohms, then
// verify against a known load (section below). Empirical beats theory.
const float CALIBRATION = 30.0;   // for SCT-013-030
const float MAINS_VOLTS = 120.0;  // US; use 230.0 for EU/UK

void setup() {
  Serial.begin(115200);
  delay(1000);
  emon1.current(34, CALIBRATION);   // ADC pin, calibration constant
}

void loop() {
  // calcVI(samples, timeout_ms): sample for a fixed window
  emon1.calcVI(20, 2000);
  float realPower  = emon1.realPower;
  float appPower   = emon1.apparentPower;
  float powerF     = emon1.powerFactor;
  float rmsCurrent = emon1.Irms;

  Serial.print("Irms: ");
  Serial.print(rmsCurrent, 2);
  Serial.print(" A  real: ");
  Serial.print(realPower, 0);
  Serial.print(" W  PF: ");
  Serial.println(powerF, 2);
  delay(2000);
}

Raw ESP32 (Arduino) without EmonLib, so you can see the math

const int CT_PIN = 34;
const float ADC_REF = 3.3;
const float ADC_MAX = 4095.0;
const float CALIBRATION = 30.0;  // SCT-013-030: 30A per 1V RMS
const float MAINS_VOLTS = 120.0;

// Sample a window and compute RMS of (v - midpoint), then scale
float readIrmsAmps() {
  const int N = 200;
  double sumSq = 0;
  double sum = 0;
  for (int i = 0; i < N; i++) {
    int raw = analogRead(CT_PIN);
    double v = raw * ADC_REF / ADC_MAX;
    sum += v;
    sumSq += v * v;
    delayMicroseconds(500);   // ~1 kHz effective, fine for 60 Hz RMS
  }
  double mean = sum / N;
  double meanSq = sumSq / N;
  double variance = meanSq - mean * mean;
  double rmsVoltage = sqrt(variance);
  return rmsVoltage * CALIBRATION;
}

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);
  delay(1000);
}

void loop() {
  float amps = readIrmsAmps();
  float watts = amps * MAINS_VOLTS;   // apparent power; PF ignored
  Serial.print("Irms: ");
  Serial.print(amps, 2);
  Serial.print(" A  ~");
  Serial.print(watts, 0);
  Serial.println(" W");
  delay(2000);
}

MicroPython

from machine import ADC, Pin
import time
import math

ct = ADC(Pin(34))
ct.atten(ADC.ATTN_11DB)   # full 0-3.3V range

CALIBRATION = 30.0   # SCT-013-030: 30A per 1V RMS
MAINS_VOLTS = 120.0

def read_irms_amps(n=200):
    readings = [ct.read_u16() for _ in range(n)]
    volts = [r / 65535 * 3.3 for r in readings]
    mean = sum(volts) / n
    rms = math.sqrt(sum((v - mean) ** 2 for v in volts) / n)
    return rms * CALIBRATION

while True:
    amps = read_irms_amps()
    watts = amps * MAINS_VOLTS
    print(f"Irms: {amps:.2f} A  ~{watts:.0f} W")
    time.sleep(2)

Calibrating against a known load

Constants get you close. A known load gets you right. Plug in a heater or a kettle with a printed wattage (e.g. a 1500W space heater), turn off everything else you can, and compare:

// If a 1500W heater reads 1290W, scale:
// newCalibration = CALIBRATION * (1500.0 / 1290.0);

Run that once, hardcode the corrected constant, and you are done. My unit came out at 27.8 instead of 30 after this step, which is a 7% error you would have lived with forever without checking.

What you learned

  • A current transformer clamps around one conductor and never touches mains metal. The output is an AC voltage proportional to current.
  • The -000 variant needs an external burden resistor; the -030 has one built in. Adding a burden to the -030 halves your output.
  • The bias divider (2x 10K) lifts the AC signal to 1.65V so the whole AC waveform fits inside the ESP32's 0 to 3.3V ADC window.
  • RMS math turns the sampled AC waveform into amps; amps times your mains voltage (120 in the US, 230 in EU) turns amps into watts.
  • Calibrate once against a known load and the reading becomes trustworthy within about 5%.

When something breaks

  • Reading is flat zero. The clamp is around both conductors, or the clamp is not fully closed. The magnetic fields cancel around a pair. Move the clamp around a single hot wire (or use a plug-in splitter cord so you can clamp the individual conductors).
  • Readings are negative or wildly noisy. The bias network is missing or wrong. Measure the divider midpoint with a multimeter: it must read about 1.65V. If it reads 3.3V or 0V, a divider resistor is in the wrong hole.
  • Sensible numbers, wrong by a constant factor. Calibration. Run the known-load test above. Do not just scale to match your utility bill over a day (e.g. the meter includes the oven spike your bench test never sees); use a single known load.
  • Values jump around between reads. You are sampling fewer than 2 full AC cycles, or other loads switch on mid-sample. Increase N to 500 in the raw version, or use EmonLib's calcVI which samples for a fixed 250 ms window.
  • Works on the bench, garbage in the panel. The clamp is picking up adjacent conductor fields, or your jumper leads are a long antenna. Keep the sensor leads short, twist them, and keep the CT away from the ESP32's switching supply.

What to build next

  • The ADS1115 external ADC tutorial is the precision upgrade: the ADS1115 has a differential input and a real PGA, which is exactly what a CT signal wants (e.g. 16 bits instead of 12, and no 0-3.3V range squeeze).
  • The MQTT publish-subscribe tutorial publishes watts to your dashboard every 10 seconds.
  • The ntfy notifications tutorial alerts your phone when whole-house draw exceeds a threshold (e.g. 8000W is a good "did I leave the dryer running" line).
  • The Raspberry Pi InfluxDB Grafana tutorial stores the watts history and draws the graph your utility company will not.

Chapter 81

ESP32: long-term air quality with the BME680 (baseline tracking over MQTT)

esp32 · 45 min

The first BME680 tutorial on this site got the four readings on screen in 25 minutes. That is the fun part. This one is the boring part, and the boring part is where air quality actually becomes useful: running the sensor for days, surviving the burn-in, finding your room's baseline, and pushing the trend somewhere you can graph it (e.g. MQTT into whatever dashboard you already run).

The trap I hit: I trusted day-one numbers. Gas resistance read 15 KOhms, I called the air dirty, and it was really just the sensor settling. By day 3 the same empty room read 40. Nothing changed in the room. The sensor changed.

What you need

Needed

  • ESP32 dev board (any WROOM board; the tutorial code uses GPIO 21/22
  • BME680 breakout (Adafruit 3660, or the purple GY-BME680 clones; both
  • 4 jumper wires (female-female if your breakout has a header)
  • An MQTT broker to publish to (e.g. Mosquitto on a Raspberry Pi, per

Nice to have

  • A soldering iron and solder (only if you solder the header pins yourself)
  • Helping hands or a vise to hold the board while you work
  • An anti-static wristband (cheap insurance for the ESP32's pins)

Wiring (same as the first BME680 tutorial)

BME680 ESP32
VCC 3.3V
GND GND
SCL GPIO 22
SDA GPIO 21
SDO GND (address 0x76) or 3.3V (0x77)
CS 3.3V (forces I2C mode on clones)

Nothing here is new. The changes that matter in this tutorial are all in time and software.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, and install both of these:

  • "Adafruit BME680 Library" (pulls in the Bosch driver)
  • "PubSubClient" by Nick O'Leary (the MQTT client)

If you do not have a broker running yet, the MQTT tutorial covers Mosquitto on a Raspberry Pi in about 10 minutes.

The code

One reading per minute, published to MQTT, with the burn-in state tracked in NVS so a power cycle does not reset your clock.

#include <Wire.h>
#include <Adafruit_BME680.h>
#include <PubSubClient.h>
#include <WiFi.h>
#include <Preferences.h>

#define SEALEVELPRESSURE_HPA (1013.25)
#define READ_INTERVAL_MS 60000UL   // one reading per minute

Adafruit_BME680 bme;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Preferences prefs;

unsigned long poweredMinutes = 0;   // burn-in clock, persisted

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);

  if (!bme.begin(0x76)) {
    Serial.println("BME680 not found (check CS tie-high on clones)");
    while (1) delay(1000);
  }
  bme.setTemperatureOversampling(BME68X_OS_16X);
  bme.setHumidityOversampling(BME68X_OS_2X);
  bme.setPressureOversampling(BME68X_OS_16X);
  bme.setIIRFilterSize(BME68X_IIR_FILTER_SIZE_3);
  bme.setGasHeater(320, 150);

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }

  mqtt.setServer("192.168.1.50", 1883);   // your broker

  prefs.begin("bme680", false);
  poweredMinutes = prefs.getULong("poweredMin", 0);
  Serial.printf("Burn-in clock: %lu minutes\n", poweredMinutes);
}

bool publishReading(float temp, float hum, float pres, float gas) {
  if (!mqtt.connected()) {
    // client id must be unique per device on the broker
    if (!mqtt.connect("bme680-aq-1")) return false;
  }
  String base = "home/air/room1";
  mqtt.publish((base + "/temperature").c_str(), String(temp, 2).c_str(), true);
  mqtt.publish((base + "/humidity").c_str(),   String(hum, 1).c_str(), true);
  mqtt.publish((base + "/pressure").c_str(),   String(pres, 1).c_str(), true);
  mqtt.publish((base + "/gas_kohms").c_str(),  String(gas, 1).c_str(), true);
  mqtt.publish((base + "/burnin_minutes").c_str(), String(poweredMinutes).c_str(), true);
  mqtt.publish((base + "/burnin_done").c_str(),
               poweredMinutes >= 2880 ? "true" : "false", true);
  return true;
}

void loop() {
  static unsigned long lastRead = 0;

  if (millis() - lastRead >= READ_INTERVAL_MS) {
    lastRead = millis();

    unsigned long endTime = bme.beginReading();
    if (endTime == 0) { Serial.println("reading failed"); return; }
    delay(endTime - millis());
    if (!bme.endReading()) { Serial.println("read incomplete"); return; }

    poweredMinutes++;   // one reading per minute = the burn-in clock
    prefs.putULong("poweredMin", poweredMinutes);

    float gasK = bme.gas_resistance / 1000.0;
    Serial.printf("T %.1f C  RH %.1f %%  P %.1f hPa  Gas %.1f KOhm  (%lu min)\n",
                  bme.temperature / 100.0, bme.humidity / 1000.0,
                  bme.pressure / 100.0, gasK, poweredMinutes);

    publishReading(bme.temperature / 100.0, bme.humidity / 1000.0,
                   bme.pressure / 100.0, gasK);
  }
  mqtt.loop();
  delay(100);
}

The burn-in, and why day-one numbers lie

Bosch specs 48 hours of powered operation before the gas channel's baseline is stable. What actually happens: the gas resistance drifts downward for the first day (sometimes most of two days), the slope flattens, and then the readings become comparable to each other. The drift is not noise. It is the heater element conditioning. There is no way to skip it, only to account for it.

Practical rules that come out of this:

  • Do not write any thresholds before hour 48. They will be wrong.
  • Do not compare today's number to last week's number unless the sensor has been powered the whole time. Every power cycle restarts some settling (the code's NVS counter makes this visible).
  • After burn-in, the only meaningful comparison is sensor-vs-itself: now vs its own clean-room baseline.

Baseline tracking: the 24-hour rolling baseline

The absolute ohm value is per-unit and per-room. What you want is a rolling window of your own readings and a deviation from that. The simple version, computable on the ESP32 itself:

// Keep a 24-hour ring of gas readings (1440 one-minute samples),
// compute the median, and publish the ratio.
const int N = 1440;
float gasRing[N];
int ringIdx = 0, ringCount = 0;

void addSample(float gasK) {
  gasRing[ringIdx] = gasK;
  ringIdx = (ringIdx + 1) % N;
  if (ringCount < N) ringCount++;
}

float baselineMedian() {
  static float scratch[N];               // avoid sorting in place
  memcpy(scratch, gasRing, ringCount * sizeof(float));
  // insertion sort is fine at this size on a 1 Hz cadence
  for (int i = 1; i < ringCount; i++) {
    float v = scratch[i]; int j = i - 1;
    while (j >= 0 && scratch[j] > v) { scratch[j+1] = v == v ? scratch[j] : scratch[j]; j--; }
    scratch[j+1] = v;
  }
  return scratch[ringCount / 2];
}

Publish gas_kohms / baseline_median as a ratio. Clean air sits near 1.0, cooking drops it to 0.2 or lower, and it recovers over an hour or two (e.g. 0.3 at 6pm, 0.9 by 8pm after dinner cooking). The ratio is what you alert on, not the raw ohms.

The ring buffer plus median costs about 6 KB of RAM and a sort of a 6 KB array once per published ratio (do it every 10 readings, not every reading, or the ESP32 spends its life sorting). Median, not mean: one cooking event should not poison the baseline it is being measured against.

Reading the trends in a dashboard

Subscribe with anything that speaks MQTT (e.g. MQTT Explorer on the desktop for a quick look, or Grafana over Mosquitto for the real setup). Two views are worth having:

  • The last 24 hours at one-minute resolution: this shows events (cooking, a window opened, a solvent smell).
  • The last 30 days at one-sample-per-hour resolution: this is your seasonal drift, heating season vs not, and the sensor's aging.

The burn-in flag matters here. Graph gas_kohms for the first week and you will see the settling curve; after that the curve flattens and events start standing out. If you publish burnin_done as a retained flag (the code above does), dashboards can grey out everything before it.

What you learned

  • The BME680 gas channel needs 48 hours of powered burn-in before baselines mean anything, and every power cycle partially restarts it.
  • Baselines are self-relative: a 24-hour rolling median turned into a ratio is the usable form, not absolute KOhms.
  • MQTT with retained flags (e.g. burnin_done) is how the sensor's state survives reboots and stays visible to every subscriber.

When something breaks

  • "BME680 not found": on the purple clones, CS must be tied to 3.3V or the chip never enumerates on I2C. Run an I2C scanner; expect 0x76 with SDO low, 0x77 with SDO high.
  • MQTT connects then drops every few minutes: you are publishing too fast for a keep-alive window, or two devices share a client ID (the broker kicks the older one). Check the client ID in mqtt.connect() is unique per board.
  • Baseline ratio pegged at 1.0 and never moves: the ring buffer never got samples. If you reset the board, ringCount restarts at 0 and the ratio is meaningless until an hour of data exists; gate the publishing on ringCount > 60.
  • Gas numbers drifted after a firmware update: the heater config changed (320 degrees, 150 ms is this tutorial's setting; the Bosch default in some examples is 300/100). Same board, same room, but a different heater profile is not the same sensor. Pin the profile.

What to build next

  • The air quality monitor project puts these readings on an OLED for the room-without-a-dashboard version.
  • The SMTP email tutorial is the right channel for the once-a-week digest ("your baseline moved 15% this week"), not for one-minute data.
  • The first BME680 tutorial covers the raw four-reading basics this post assumes.
  • The book IoT with ESP32 bundles the sensor tutorials.

Chapter 82

ESP32-CAM: take and email a photo on motion (SMTP)

esp32 · 45 min

The photo-on-motion tutorial pushes the picture over ntfy, which is great when you are the only person who needs it and the only person willing to install an app. Email is the version for everyone else: the neighbor watching your driveway while you travel, the family member with the iPhone and no patience, the archive you can search in ten years. Email never dies, and every mail server on earth accepts an attachment.

The trap I hit: I reused the plain-alert code from the SMTP tutorial and tried to jam base64 into the message body by hand. The result was a wall of text that no mail client rendered as an image. Email attachments are MIME multipart, the boundary lines have exact semantics, and the library does it right (e.g. this is the same hand-rolling trap as formatting your own TLS: possible, not worth it).

What you need

Needed

  • ESP32-CAM board (AI-Thinker, about $10)
  • FTDI USB-serial adapter for programming (the board has no USB port)
  • HC-SR501 PIR motion sensor (about $2)
  • 5V supply rated 500 mA or better (a phone charger; camera brownouts
  • An SMTP account with an app password (e.g. your own mail server, or

Nice to have

  • A soldering iron and solder (only if you solder the header pins yourself)
  • Helping hands or a vise to hold the board while you work
  • An anti-static wristband (cheap insurance for the ESP32's pins)

Wiring

The FTDI programming wiring (same dance as every ESP32-CAM tutorial):

FTDI ESP32-CAM
5V 5V
GND GND
TX U0R (GPIO 3)
RX U0T (GPIO 1)
(n/a) GPIO 0 to GND during power-up (download mode)

The GPIO 0-to-GND jumper is the step everyone misses. Ground GPIO 0, power the board, upload, then remove the jumper and press reset.

The PIR wiring (motion trigger side):

PIR (HC-SR501) ESP32-CAM
VCC 5V
GND GND
OUT GPIO 13

GPIO 13 is free on this board (not part of the camera or SD bus), and the HC-SR501's output is 3.3V logic even powered from 5V, so no level shifter. Set the PIR's time-delay pot fully counterclockwise (shortest hold time) and the sensitivity pot to the middle.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "ESP32 MailClient", install the one by Mobizt (the library is ESP_Mail_Client). It handles STARTTLS and the MIME encoding, which are the two parts you do not want to hand-roll.

Board support for the ESP32-CAM: Tools >> Board >> ESP32 Arduino >> AI Thinker ESP32-CAM. If "ESP32 Arduino" is not in the list, the toolchain install tutorial covers it first.

The code

#include "esp_camera.h"
#include <WiFi.h>
#include <ESP_Mail_Client.h>

// AI-Thinker pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y2_GPIO_NUM     5
#define Y3_GPIO_NUM    18
#define Y4_GPIO_NUM    19
#define Y5_GPIO_NUM    21
#define Y6_GPIO_NUM    36
#define Y7_GPIO_NUM    39
#define Y8_GPIO_NUM    34
#define Y9_GPIO_NUM    35
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

#define PIR_PIN 13

#define SMTP_HOST "mail.yourdomain.com"
#define SMTP_PORT 587
#define SMTP_EMAIL "alerts@yourdomain.com"
#define SMTP_PASSWORD "your-app-password"
#define TO_EMAIL "you@yourdomain.com"

SMTPSession smtp;
Session_Config config;

bool cameraReady = false;

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);

  camera_config_t camcfg = {};
  camcfg.ledc_channel = LEDC_CHANNEL_0;
  camcfg.ledc_timer = LEDC_TIMER_0;
  camcfg.pin_d0 = Y2_GPIO_NUM;  camcfg.pin_d1 = Y3_GPIO_NUM;
  camcfg.pin_d2 = Y4_GPIO_NUM;  camcfg.pin_d3 = Y5_GPIO_NUM;
  camcfg.pin_d4 = Y6_GPIO_NUM;  camcfg.pin_d5 = Y7_GPIO_NUM;
  camcfg.pin_d6 = Y8_GPIO_NUM;  camcfg.pin_d7 = Y9_GPIO_NUM;
  camcfg.pin_xclk = XCLK_GPIO_NUM;
  camcfg.pin_pclk = PCLK_GPIO_NUM;
  camcfg.pin_vsync = VSYNC_GPIO_NUM;
  camcfg.pin_href = HREF_GPIO_NUM;
  camcfg.pin_sccb_sda = SIOD_GPIO_NUM;
  camcfg.pin_sccb_scl = SIOC_GPIO_NUM;
  camcfg.pin_pwdn = PWDN_GPIO_NUM;
  camcfg.pin_reset = RESET_GPIO_NUM;
  camcfg.xclk_freq_hz = 20000000;
  camcfg.pixel_format = PIXFORMAT_JPEG;
  camcfg.frame_size = FRAMESIZE_VGA;
  camcfg.jpeg_quality = 12;
  camcfg.fb_count = 1;

  cameraReady = (esp_camera_init(&camcfg) == ESP_OK);
  Serial.println(cameraReady ? "camera ok" : "camera FAILED (check power)");

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }

  config.server.host_name = SMTP_HOST;
  config.server.port = SMTP_PORT;
  config.login.email = SMTP_EMAIL;
  config.login.password = SMTP_PASSWORD;
  config.login.user_domain = "yourdomain.com";
  config.secure.mode = sec_modes::sec_starttls;   // port 587 pairing
}

bool sendPhotoEmail() {
  if (!cameraReady) return false;

  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) { Serial.println("capture failed"); return false; }

  SMTP_Message msg;
  msg.sender.name = "Driveway Cam";
  msg.sender.email = SMTP_EMAIL;
  msg.subject = "Motion at " + String(millis() / 1000) + "s uptime";
  msg.addRecipient("You", TO_EMAIL);
  msg.text.content = "The PIR fired. Photo attached.";

  // The attachment: the raw JPEG frame, base64-encoded by the library
  msg.addAttachment(SMTP_Attachment(
    /* filename */ "motion.jpg",
    /* mime     */ "image/jpeg",
    /* data     */ (const uint8_t*)fb->buf,
    /* len      */ fb->len));

  bool ok = MailClient.sendMail(&smtp, &msg);
  if (!ok) { Serial.print("SMTP failed: "); Serial.println(smtp.errorReason()); }
  esp_camera_fb_return(fb);
  return ok;
}

void loop() {
  if (cameraReady && digitalRead(PIR_PIN) == HIGH) {
    unsigned long start = millis();
    bool sent = sendPhotoEmail();
    Serial.printf("send took %lu ms, ok=%d\n", millis() - start, sent);
    delay(30000);   // cool-down: SMTP per minute is plenty, and polite
  }
  delay(200);
}

Why email, when ntfy exists

They are different tools, and the camera deserves both:

ntfy SMTP
Delivery speed ~2 s 5-30 s
Recipient whoever subscribes any email address
App install yes no
Attachment size fine at VGA fine at VGA
Search/archive weak the mail client's job

Email wins when the recipient list is people who will never configure an app, and when you want the photos searchable in one place. ntfy wins when you want your own phone buzzed now (e.g. both channels on the same PIR event is a two-line change: call both send functions).

The Wi-Fi reconnect trap

SMTP is a long conversation (connect, TLS handshake, AUTH, send, quit: 5-15 seconds total). If the router reboots overnight, the PIR can fire with Wi-Fi down and the mail fails silently. Check and reconnect before trusting the alert:

bool wifiOk() {
  if (WiFi.status() == WL_CONNECTED) return true;
  WiFi.disconnect();
  WiFi.reconnect();
  delay(3000);
  return WiFi.status() == WL_CONNECTED;
}

Call it at the top of the motion branch, and skip the PIR event entirely when it returns false (a missed photo is better than a rebooting device).

What you learned

  • The SMTP attachment is a MIME part; the Mobizt library takes the raw frame buffer pointer and length and does the encoding.
  • Camera init plus TLS handshake plus send is a 10-second event: plan power and cool-downs around it (e.g. 30 s here, not 3 s).
  • The PIR wiring is one GPIO; the email path is the same SMTP session pattern as the plain-alert tutorial, plus one attachment.

When something breaks

  • "Camera init failed": the power supply, almost every time. The camera and the radio together pull 300+ mA in bursts. Use a real 5V 500 mA+ source and a short thick cable.
  • "Authentication failed": the provider wants an app password, not the account password (e.g. Google Account >> Security >> 2-Step Verification >> App passwords). Create one, revoke-able per device.
  • Mail sends but no attachment: some providers strip attachments over a size or quarantine unscanned ones. Send to your own address first, and check the raw message source in the mail client for the MIME boundary sections.
  • Mail goes to spam: your sending domain lacks SPF/DKIM coverage for that server. That is DNS, not the ESP32. The SMTP tutorial has the details.
  • Works on USB, browns out when the PIR also fires: the PIR's 5V and the camera's burst current share a thin wire harness. Separate supplies or a 2A source.

What to build next

  • The photo-on-motion ntfy tutorial is the push-notification sibling; run both from the same PIR event.
  • The SMTP alerts tutorial covers the account setup, app passwords, and the MailHog test rig in depth.
  • The streaming tutorial is the live-view version of this board.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 83

ESP32: tones and waveforms from the true DAC

esp32 · 30 min

Most "ESP32 audio" tutorials you will find use PWM and a low-pass filter. That works, but the ESP32 has something better hiding on two pins: a true 8-bit DAC on GPIO25 and GPIO26. Real analog voltage out, no filter, no PWM whine. I found this out the annoying way, after building a PWM tone generator for a door chime and then reading the datasheet and finding two DACs staring back at me the whole time.

The trap is that these two pins are easy to kill and impossible to miss once you know: GPIO25 and GPIO26 are also the pins many ESP32 breakouts use for the SD card slot or the PSRAM interface (e.g. on the WROVER-based boards). If your board has those, the DAC pins may be taken. Check before you design around them.

What you need

Needed

  • ESP32 dev board with GPIO25/26 exposed (e.g. ESP32-DevKitC, about $8; avoid WROVER modules for this project since their PSRAM uses GPIO16/17 and some boards route SD to 25/26).
  • 8 ohm speaker or piezo transducer.
  • 1 resistor, 220 ohm (series protection for the speaker).
  • 1 capacitor, 10 uF electrolytic (AC coupling, blocks the DC bias).
  • Breadboard + 4 jumper wires.
  • 3.5mm aux cable or alligator clips, to connect to a speaker or amp.

Nice to have

  • Soldering iron + solder, if you attach headers to a bare module.
  • Soldering iron stand, for parking the hot iron.
  • Helping hands, to hold wires while soldering.
  • Anti-static wristband, for bare-module work.
  • Magnifying goggles, for reading the tiny pin labels on compact boards.
  • Soldering mat, to protect the desk.
  • Wire stripper, for clean speaker-wire ends.

Wiring

From Connect to
ESP32 GPIO25 (DAC1) 220 ohm resistor -> speaker positive
Speaker negative 10 uF capacitor + (then cap - to GND)
ESP32 GND Speaker return / cap negative

For a piezo buzzer you can drive GPIO25 straight to the buzzer (piezo draws almost nothing). For an 8 ohm speaker, the 220 ohm resistor in series is not optional; it protects the DAC pin from overcurrent. The 10 uF cap in series with the speaker blocks the DC half of the signal (speakers only care about AC; DC just heats the coil).

GPIO25 ---[220R]---+--- speaker + 
                   |
              (10uF cap in series with speaker, + toward GPIO25 side)
                   |
               speaker - --- GND

Only pins GPIO25 (DAC1) and GPIO26 (DAC2) have true DAC output. Any other pin gives you PWM, which is a different technique entirely (see the PWM LEDC tutorial). If your board is a DevKit-V1 clone with unlabeled pins, check the pinout before wiring; some clones mislabel.

Install

No libraries needed for the Arduino framework: the ESP32 Arduino core ships with the DAC API. Just make sure the ESP32 board package is installed (Arduino IDE >> Tools >> Board >> Boards Manager >> search "esp32" >> install).

The code

Beeps and tones (Arduino)

The ESP32 Arduino core v3 has a dacWrite function and a dacOutputVoltage API. The simplest possible tone is a square wave by hand:

const int DAC_PIN = 25;   // GPIO25 = DAC1

// Square wave tone: flip between 0V and 3.3V at the note's frequency
void toneRaw(int freqHz, int durationMs) {
  long halfPeriodUs = 500000L / freqHz;
  long cycles = (long)freqHz * durationMs / 1000;
  for (long c = 0; c < cycles; c++) {
    dacWrite(DAC_PIN, 255);        // 3.3V
    delayMicroseconds(halfPeriodUs);
    dacWrite(DAC_PIN, 0);          // 0V
    delayMicroseconds(halfPeriodUs);
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);
}

void loop() {
  toneRaw(440, 500);   // A4: concert A
  delay(500);
  toneRaw(880, 500);   // A5: the octave above
  delay(2000);
}

This is the "blink an LED but with sound" version, and it sounds like it (harsh, buzzy, with harmonics everywhere). Sometimes that is exactly the right sound for an alarm. For nicer tones you want a sine wave.

Sine wave (the real DAC advantage)

Generate a sine table once, then walk through it at a rate that sets the pitch:

const int DAC_PIN = 25;

// 256-sample sine table, 8-bit DAC values (0-255), centered at 128
uint8_t sineTable[256];
void buildSineTable() {
  for (int i = 0; i < 256; i++) {
    sineTable[i] = (uint8_t)(128.0 + 127.0 * sin(2.0 * PI * i / 256.0));
  }
}

// Play a sine at freqHz using the table (sample rate = freqHz * 256)
void playSine(int freqHz, int durationMs) {
  const int TABLE_SIZE = 256;
  long totalSamples = (long)freqHz * TABLE_SIZE / 1000 * durationMs / 1000;
  // Simpler: compute total samples directly
  totalSamples = (long)freqHz * durationMs / 1000 * TABLE_SIZE;
  int step = 1;
  long idx = 0;
  unsigned long nextSampleUs = micros();
  unsigned long samplePeriodUs = 1000000UL / (freqHz * TABLE_SIZE);
  while (idx < totalSamples) {
    if (micros() >= nextSampleUs) {
      dacWrite(DAC_PIN, sineTable[idx % TABLE_SIZE]);
      idx++;
      nextSampleUs += samplePeriodUs;
    }
  }
}

void setup() {
  Serial.begin(115200);
  buildSineTable();
  delay(1000);
}

void loop() {
  playSine(440, 1000);   // A4 for one second, smooth and clean
  delay(1000);
}

The difference is audible immediately. A square wave at 440 Hz sounds like an old arcade game. The sine at 440 Hz sounds like a tuning fork. Same pitch, very different quality, and the only difference is the waveform you write to the DAC.

Ramps and arbitrary waveforms (the real flexibility)

The DAC does not care what numbers you send it. Triangle, sawtooth, noise, whatever:

const int DAC_PIN = 25;

void playSawtooth(int freqHz, int durationMs) {
  long cycles = (long)freqHz * durationMs / 1000;
  for (long c = 0; c < cycles; c++) {
    for (int i = 0; i <= 255; i++) {
      dacWrite(DAC_PIN, i);
      delayMicroseconds(1000000L / (freqHz * 256));
    }
  }
}

void playTriangle(int freqHz, int durationMs) {
  long cycles = (long)freqHz * durationMs / 1000;
  for (long c = 0; c < cycles; c++) {
    for (int i = 0; i <= 255; i++) dacWrite(DAC_PIN, i);
    for (int i = 255; i >= 0; i--) dacWrite(DAC_PIN, i);
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);
}

void loop() {
  playSawtooth(220, 800);
  delay(400);
  playTriangle(220, 800);
  delay(2000);
}

Sine sweep (great for testing filters and speakers)

const int DAC_PIN = 25;

// Sweep from f1 to f2 over durationMs using the sine table
void sweep(int f1, int f2, int durationMs) {
  const int TABLE = 256;
  unsigned long start = millis();
  while (millis() - start < (unsigned long)durationMs) {
    float t = (float)(millis() - start) / durationMs;   // 0.0 to 1.0
    int freq = f1 + (int)((f2 - f1) * t);
    unsigned long samplePeriodUs = 1000000UL / (freq * TABLE);
    for (int i = 0; i < TABLE; i++) {
      dacWrite(DAC_PIN, sineTable[i]);
      delayMicroseconds(samplePeriodUs);
    }
  }
}

void setup() {
  Serial.begin(115200);
  buildSineTable();
  delay(1000);
}

void loop() {
  sweep(200, 2000, 3000);   // 200 Hz to 2 kHz in 3 seconds
  delay(2000);
}

What you should hear

The square wave is the loudest (all those harmonics carry energy). The sine is the quietest and cleanest. The sawtooth sits between. If everything sounds identical, your speaker is too small to reproduce the difference, or you are listening to the piezo, which distorts everything into the same buzz anyway.

The DAC output is 0 to 3.3V with 256 steps (8-bit). Each step is 12.9 mV. That is plenty for tones, but not enough for hi-fi. When you outgrow it, the I2S microphone tutorial is the receive side of real audio, and an external I2S DAC breakout (e.g. PCM5102, about $5) is the 16-bit playback side.

What you learned

  • GPIO25 and GPIO26 are true analog outputs (8-bit DAC, 0 to 3.3V in 256 steps). Everything else is PWM wearing a filter.
  • Writing values in a timed loop generates any waveform: square, sine, sawtooth, triangle, noise. The sample timing sets the pitch.
  • A sine table computed once and replayed is the cheap way to smooth audio on a chip with no FPU-heavy DSP budget (e.g. 256 samples covering one full cycle, walked at a rate proportional to frequency).
  • Series resistor and AC-coupling cap protect the DAC pin and the speaker; the piezo can be driven directly.

When something breaks

  • No sound at all. Check the pin: dacWrite silently does nothing on pins that are not 25 or 26. Print the pin number and confirm it is GPIO25. Then check the series resistor and speaker wiring; a piezo with a broken lead reads as silence too.
  • Sound is very quiet. Expected: the DAC drives milliwatts. Use a piezo for beeps, a small amp module (e.g. PAM8302, about $3) for a real speaker, or powered PC speakers through the coupling cap.
  • Tone plays but crackles or stutters. Your loop is too slow at high frequencies; dacWrite plus delayMicroseconds is fine up to a few kHz but not beyond. Lower the frequency, reduce the table size, or move to I2S for anything above 10 kHz.
  • The board reboots when audio starts. You are probably on a board where GPIO25/26 are used by flash or PSRAM. Check your board variant (e.g. WROVER modules claim GPIO16/17 for PSRAM and some carriers route 25/26 to SD); move the audio to GPIO26 and free 25, or change boards.
  • Distorted sine wave. Your sample timing drifted: the micros()-based scheduler above can slip when dacWrite is slow. Use a hardware timer interrupt or lower the frequency; distortion from timing jitter is the giveaway.

What to build next

  • The I2S microphone tutorial is the receive side: record real audio with an INMP441 and pipe it back out through this DAC.
  • The buzzer tone tutorial covers the PWM/LEDC approach for simple beeps when you do not have a true-DAC pin free (e.g. a one-note door chime does not need an 8-bit DAC).
  • The ntfy notifications tutorial pairs with this for a doorbell that beeps locally and pings your phone.
  • The MQTT publish-subscribe tutorial lets you trigger tones remotely: publish to a topic, the ESP32 subscribes and plays a chime.

Chapter 84

ESP32-CAM: face detection with the onboard model

esp32 · 40 min

The ESP32-CAM's OV2640 sensor is the boring half of the story. The interesting half is that Espressif ships a small neural model (ESP-WHO, built on their ESP-DL library) that runs on the ESP32 itself and draws boxes around human faces in the camera stream. No cloud, no round trip, no API key. The $10 board does it at a few frames per second, which is enough to know "a person is looking at the camera" vs "the cat walked by".

The trap I hit: the first sketch I tried ran detection on UXGA frames and the board rebooted every few seconds. Detection needs RAM, the frame buffer needs RAM, and the ESP32 has 520 KB total. Detection wants small frames (e.g. 240x240) and PSRAM for the model working area. Get the frame size wrong and the failure mode is a reboot loop that looks like a power problem.

What you need

Needed

  • ESP32-CAM board (AI-Thinker, about $10; it has 4 MB PSRAM, which the
  • FTDI USB-serial adapter for programming (GPIO 0 dance, as always)
  • 5V supply rated 500 mA or better (the camera plus the radio plus
  • The ESP32 Arduino core 2.x installed (Tools >> Board >> ESP32

Nice to have

  • A soldering iron and solder (only if you solder the header pins yourself)
  • Helping hands or a vise to hold the board while you work
  • An anti-static wristband (cheap insurance for the ESP32's pins)

Wiring

No sensor wiring this time: the only hardware is the FTDI for programming.

FTDI ESP32-CAM
5V 5V
GND GND
TX U0R (GPIO 3)
RX U0T (GPIO 1)
(n/a) GPIO 0 to GND during power-up (download mode)

Ground GPIO 0, power the board, upload, remove the jumper, press reset. If uploads fail with "Failed to connect", the board was not in download mode when it powered up.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "ESP32", and install "ESP32" by Espressif Systems (the board support package; the detection model ships inside it, no separate download). The library name is esp32-camera plus the bundled esp32-camera detection headers, and the human face detection model is compiled in when you include the right headers.

If your core is older than 2.x, upgrade first (Arduino IDE >> Tools >> Board >> Boards Manager, search "esp32"): the 1.0.x core predates the detection API used here.

The code

#include "esp_camera.h"
#include <WiFi.h>
#include "img_converters.h"
#include "fb_gfx.h"
#include "human_face_detect_msr01.hpp"
#include "human_face_detect_mnp01.hpp"

// AI-Thinker pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y2_GPIO_NUM     5
#define Y3_GPIO_NUM    18
#define Y4_GPIO_NUM    19
#define Y5_GPIO_NUM    21
#define Y6_GPIO_NUM    36
#define Y7_GPIO_NUM    39
#define Y8_GPIO_NUM    34
#define Y9_GPIO_NUM    35
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

WebServer server(80);
bool cameraReady = false;
int lastFaceCount = 0;

void setup() {
  Serial.begin(115200);

  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_RGB565;   // detection needs RGB565
  config.frame_size = FRAMESIZE_240X240;    // the detection sweet spot
  config.fb_count = 2;
  config.grab_mode = CAMERA_GRAB_LATEST;

  cameraReady = (esp_camera_init(&config) == ESP_OK);
  Serial.println(cameraReady ? "camera ok" : "camera FAILED (check power)");

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }

  server.on("/", handleStatus);
  server.begin();
}

void handleStatus() {
  String json = "{\"faces\":" + String(lastFaceCount) + "}";
  server.send(200, "application/json", json);
}

void loop() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) { delay(100); return; }

  // The model: MSR (light) finds faces, MNP filters false positives
  HumanFaceDetectMSR01 detector;
  detector.set_threshold(0.7f, 0.3f);   // score gate, then NVR threshold
  std::list<HumanFaceDetectMSR01::result_t> results =
      detector.infer((uint16_t *)fb->buf, {(int)fb->width, (int)fb->height});
  lastFaceCount = results.size();

  // Optional: draw boxes into the frame before returning the buffer
  if (lastFaceCount > 0) {
    for (auto &r : results) {
      fb_gfx_drawFastHLine(fb, r.box.x, r.box.y, r.box.w, 0xFFFFFF);
      fb_gfx_drawFastHLine(fb, r.box.x, r.box.y + r.box.h, r.box.w, 0xFFFFFF);
      fb_gfx_drawFastVLine(fb, r.box.x, r.box.y, r.box.h, 0xFFFFFF);
      fb_gfx_drawFastVLine(fb, r.box.x + r.box.w, r.box.y, r.box.h, 0xFFFFFF);
    }
  }

  esp_camera_fb_return(fb);
  server.handleClient();
  delay(50);   // a few fps is the honest ceiling on this chip
}

Open http://<the-ip>/ in a browser and you get a JSON count of faces in the last frame (e.g. {"faces":1} when you sit down in front of it). The serial log prints the same number; the drawn-box version feeds the streaming tutorial's MJPEG path if you want to see it live.

Detection vs recognition: what this is and is not

Detection finds faces and counts them. Recognition names them ("that is Brian"), and the recognition model is heavier: it runs, but slowly, and this post is about the detection path that stays real-time. Do not build a door lock on face recognition from a $10 board and call it security; treat detection as a presence signal (e.g. "someone is at the door" vs "nothing is there"), and let your automation decide what that means.

The PSRAM and frame size story

The model's working buffers want PSRAM, and the ESP32-CAM's 4 MB is enough at 240x240. What breaks:

  • FRAMESIZE_240X240 with PIXFORMAT_RGB565: the supported path.
  • QVGA (320x240) works but detection slows to 1-2 fps.
  • UXGA with detection: reboot loop. The frame buffer plus the model working area do not fit.

The grab_mode = CAMERA_GRAB_LATEST line matters too: with two frame buffers you want the newest frame, not a queue of stale ones (e.g. a detection result on a 2-second-old frame is the wrong answer for a doorbell).

What you learned

  • The face detection model ships inside the ESP32 Arduino core; the hardware part is frame size and pixel format, not wiring.
  • RGB565 at 240x240 is the supported configuration; bigger frames trade into reboots.
  • Detection counts faces; recognition names them, and they are different budgets on this chip.

When something breaks

  • "Camera init failed": power supply first (the camera's 300 mA bursts brownout weak USB). If power is good, check that PIXFORMAT_RGB565 is set; JPEG frames cannot be fed to the model.
  • Reboot loop after a few seconds of detection: frame size too big for the model's RAM budget. Drop to 240x240 and make sure fb_count is 2, not more.
  • Faces detected but count flickers 0/1/0: the score threshold is too loose. Raise the first set_threshold() value toward 0.8 (e.g. 0.7 catches profile faces at the cost of a few false hits on posters and pets' faces).
  • 0.5 fps and getting worse: PSRAM is not being detected. Run the board-support example ESP32 >> Camera >> CameraWebServer once; its boot log prints "PSRAM: OK" or "PSRAM: FAILED". A failed PSRAM module is a returned-board situation.
  • Upload fails: GPIO 0 was not grounded at power-up. Same dance as every ESP32-CAM post.

What to build next

  • The streaming tutorial is the live-view base; add this detection loop to it and the boxes appear in the browser.
  • The photo-on-motion ntfy tutorial pairs with this: gate the photo on a detected face and the cat stops filing reports.
  • The edge AI image classification tutorial is the broader what-can-this-chip-run follow-up.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 85

ESP32: location and time from a NEO-M8N GPS module

esp32 · 30 min

I put a NEO-M8N on my windowsill, powered it up, and stared at an empty NMEA stream for five minutes wondering if I had wired it wrong. I had not. The module was fine, my wiring was fine, and the chip was doing exactly what GPS modules do: downloading the almanac from every satellite in view, one 50 bits-per-second signal at a time. A cold fix genuinely takes minutes (e.g. 27 seconds is the spec-sheet best case for a cold start; indoors it can be never).

The trap is expecting GPS to behave like an I2C sensor. It does not. There is no "read the register" moment. The module blasts NMEA sentences over serial continuously, and your job is to wait for one that contains a valid fix. Indoors, that may never come, and the module is not broken: it just cannot see the sky.

What you need

Needed

  • ESP32 dev board (e.g. ESP32-DevKitC, about $8).
  • NEO-M8N GPS module with ceramic patch antenna (the u-blox M8 engine on a blue breakout board, about $15). The NEO-6M is the older and cheaper variant and also works with this wiring; the M8N adds GLONASS and Galileo, which means faster fixes and more satellites.
  • 4 jumper wires, female-female (GPS breakouts usually have male pin headers pre-soldered).

Nice to have

  • Active external antenna (u.FL connector, about $8) if you plan to mount the ESP32 somewhere the patch antenna cannot see the sky.
  • USB-serial adapter (CP2102/FTDI), to inspect raw NMEA sentences from a laptop without the ESP32 in the loop.
  • Soldering iron + solder, if the module ships with a bare header.
  • Soldering iron stand, for safe parking of the hot iron.
  • Helping hands, to hold the header straight while soldering.
  • Anti-static wristband, for bare-module handling.
  • Magnifying goggles, for reading the tiny u.FL silkscreen labels.
  • Soldering mat, to keep the desk clean.
  • Wire stripper, for clean antenna lead ends.

Wiring

Serial, crossed. GPS TX talks to ESP32 RX:

GPS module Connect to
VCC ESP32 3V3 (5V works on most breakouts but 3V3 is safer)
GND ESP32 GND
TX ESP32 GPIO 16 (RX2)
RX ESP32 GPIO 17 (TX2)

The ESP32 has three hardware UARTs. UART2 is free on standard boards, which is why we use GPIO 16/17 instead of the USB-connected UART0.

Some cheap NEO-M8N boards are actually NEO-6M chips relabeled. It does not matter for this tutorial: both speak NMEA at 9600 baud and both work with the same code. If you want to know which one you got, the NMEA talker ID and message set differ slightly (e.g. the M8N emits GLM and GAL sentences you will never see from a 6M).

The patch antenna faces up. Ceramic side toward the sky, metal ground plane side down. Running it face down halves your signal.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search "TinyGPSPlus" >> install (Mikal Hart's TinyGPSPlus, formerly TinyGPS++). It parses NMEA properly, including checksums, and does not block your loop.

The code

ESP32 (Arduino) with TinyGPSPlus

#include <TinyGPSPlus.h>
#include <HardwareSerial.h>

TinyGPSPlus gps;
HardwareSerial GPSSerial(2);   // UART2: GPIO 16 = RX2, GPIO 17 = TX2

void setup() {
  Serial.begin(115200);
  delay(1000);
  GPSSerial.begin(9600, SERIAL_8N1, 16, 17);   // RX=16, TX=17
  Serial.println("Waiting for GPS fix... (go outside or open a window)");
}

void loop() {
  // Feed every byte from GPS into the parser
  while (GPSSerial.available() > 0) {
    gps.encode(GPSSerial.read());
  }

  // Print once per second when we have a fix
  static unsigned long lastPrint = 0;
  if (millis() - lastPrint > 1000) {
    lastPrint = millis();
    if (gps.location.isValid()) {
      Serial.print("Lat: ");
      Serial.print(gps.location.lat(), 6);
      Serial.print("  Lng: ");
      Serial.print(gps.location.lng(), 6);
      Serial.print("  Sats: ");
      Serial.print(gps.satellites.value());
      Serial.print("  HDOP: ");
      Serial.println(gps.hdop.value() / 100.0, 2);
    } else {
      Serial.print("No fix yet. Sats heard: ");
      Serial.println(gps.satellites.isValid() ? gps.satellites.value() : 0);
    }
    if (gps.time.isValid()) {
      char buf[32];
      snprintf(buf, sizeof(buf), "%02d:%02d:%02d UTC",
               gps.time.hour(), gps.time.minute(), gps.time.second());
      Serial.print("Time: ");
      Serial.println(buf);
    }
  }
}

Reading UTC time and date (the other thing GPS gives you)

GPS time comes off atomic clocks. It is the most accurate clock you can own for free, and it needs no internet:

void printGpsTime() {
  if (gps.date.isValid() && gps.time.isValid()) {
    char buf[40];
    snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d UTC",
             gps.date.year(), gps.date.month(), gps.date.day(),
             gps.time.hour(), gps.time.minute(), gps.time.second());
    Serial.println(buf);
    // Set the system clock from GPS:
    // struct tm t = {0};
    // t.tm_year = gps.date.year() - 1900;
    // t.tm_mon  = gps.date.month() - 1;
    // t.tm_mday = gps.date.day();
    // t.tm_hour = gps.time.hour();
    // t.tm_min  = gps.time.minute();
    // t.tm_sec  = gps.time.second();
    // time_t utc = mktime(&t) - timezoneOffsetSeconds;
    // timeval now = { .tv_sec = utc };
    // settimeofday(&now, nullptr);
  }
}

MicroPython

from machine import UART, Pin
import time

gps_uart = UART(2, baudrate=9600, tx=17, rx=16)

def parse_gga(line):
    # $GPGGA,time,lat,N,lon,E,fix,sats,hdop,alt,...
    parts = line.split(',')
    if len(parts) < 7 or parts[6] == '0':
        return None
    def deg(val, hemi):
        # NMEA lat ddmm.mmmm -> decimal degrees
        d = float(val[:2] if hemi in ('N','S') else val[:3])
        m = float(val[2:] if hemi in ('N','S') else val[3:])
        dec = d + m / 60.0
        return -dec if hemi in ('S','W') else dec
    lat = deg(parts[2], parts[3])
    lon = deg(parts[4], parts[5])
    sats = int(parts[7])
    t = parts[1]
    utc = f"{t[0:2]}:{t[2:4]}:{t[4:6]}"
    return {"lat": lat, "lon": lon, "sats": sats, "utc": utc}

while True:
    if gps_uart.any():
        raw = gps_uart.readline()
        try:
            line = raw.decode('ascii').strip()
        except UnicodeDecodeError:
            line = ''
        if line.startswith('$GPGGA') or line.startswith('$GNGGA'):
            fix = parse_gga(line)
            if fix:
                print(f"Lat {fix['lat']:.6f}  Lon {fix['lon']:.6f}  "
                      f"Sats {fix['sats']}  UTC {fix['utc']}")
    time.sleep_ms(200)

First fix expectations

Take the board outside or put it on a windowsill with sky view. The first fix after power-on from cold takes 1 to 5 minutes (cold start: the module has no almanac and no ephemeris). After that, warm starts take seconds. If the module has a backup battery or stays powered, the next boot is even faster because the ephemeris data is still valid.

If you have never gotten a fix, check the antenna orientation and move outside. Fix problems are 90% antenna placement, 10% everything else.

What you learned

  • GPS modules do not work like I2C sensors. They stream NMEA over serial and you parse it; the fix arrives when the sky math is done, not when you ask for it.
  • UART2 (GPIO 16/17) is the right serial port for GPS on the ESP32; UART0 is wired to the USB chip.
  • TinyGPSPlus handles checksummed NMEA parsing without blocking your loop (e.g. feed bytes in loop(), check validity whenever you want).
  • GPS time is UTC, atomic-clock accurate, and available with no internet connection. It is the best time source you can own for free.
  • A cold fix takes minutes. Put the antenna where it can see the sky.

When something breaks

  • No NMEA data at all. TX and RX are swapped. GPS TX goes to ESP32 RX (GPIO 16). Swap them and watch data appear. If still nothing, check that the module's power LED is lit and the baud rate is 9600.
  • NMEA data but no fix for 10+ minutes. You are indoors under a metal roof, or the antenna is face down. Move near a window or go outside. Some buildings (concrete plus rebar) block GPS entirely.
  • Fix drops every few minutes. Antenna marginal. Add the external active antenna via the u.FL connector, or move the patch antenna away from the ESP32 (the Wi-Fi radio does not help reception).
  • Time is exactly 8 hours off (or your timezone). GPS time is UTC. Your local time zone offset is your job, not the GPS's (e.g. Mountain time is UTC minus 7 during daylight saving, minus 6 in winter).
  • Garbage characters instead of NMEA. Baud rate mismatch. Most modules default to 9600; some clones run 115200 or 38400. Try GPSSerial.begin(115200, ...) if 9600 gives you noise.

What to build next

  • The ntfy notifications tutorial sends a phone alert when a device leaves a geofence (e.g. publish the GPS coordinates over MQTT and let a home server decide if the fence was crossed).
  • The MQTT publish-subscribe tutorial streams the coordinates to your dashboard once per minute.
  • The I2S microphone tutorial combines with GPS for a trail recorder: position, time, and audio in one log.
  • The deep sleep tutorial makes a GPS tracker that sleeps between fixes (e.g. wake, fix, publish, sleep for 10 minutes; weeks of battery).

Chapter 86

ESP32: connect to Home Assistant two ways (ESPHome and MQTT)

esp32 · 45 min

You have a Raspberry Pi running Home Assistant (the HA install tutorial covers that part), and you have an ESP32 with a BME280 on it. The question is how the two of them talk. There are two good answers and they both work: flash the ESP32 with ESPHome, which turns the whole firmware into a YAML config file that Home Assistant manages for you, or keep your own Arduino code and publish MQTT discovery messages that make the sensor show up in HA on its own.

I run both in my house. ESPHome for the boring sensors (temperature, door contacts, the mailbox), native MQTT for anything where I want full control of the loop (e.g. the projects where the ESP32 is also driving neopixels or reading a weird sensor that needs custom timing).

The trap: people try to do both at once on one device, or they flash ESPHome and then wonder why their Arduino sketch is gone. Flashing ESPHome replaces the firmware completely. Pick one path per device. You can reflash to the other path any time over USB.

What you need

  • ESP32 dev board (the 38-pin ESP32-WROOM-32 devkit is the default)
  • A BME280 sensor module for the example (or any I2C sensor you have; the wiring is the same for the ESPHome version either way)
  • A Raspberry Pi (or any machine) running Home Assistant, reachable from your network
  • A Mosquitto MQTT broker if you take the MQTT path (the MQTT broker on Pi tutorial covers the install)
  • 4 jumper wires and a breadboard
  • Micro-USB cable for flashing

Needed

Part Why this one
ESP32-WROOM-32 devkit The board every other tutorial here assumes
BME280 breakout I2C, 3.3 V safe, HA has a native sensor entity for it
4 jumper wires SDA, SCL, VCC, GND

Nice to have

  • Soldering iron and solder (if the BME280 header came loose)
  • Multimeter (check 3.3 V actually reaches the sensor before blaming the config)
  • Helping hands (holding a header while soldering)

Wiring

BME280 ESP32
VCC 3.3V
GND GND
SDA GPIO 21
SCL GPIO 22

Same wiring for both paths. The ESP32 default I2C pins (SDA 21, SCL 22) work in ESPHome and in Arduino code alike.

The BME280 is 3.3 V only. Do not feed it 5 V. And check the module: some GY-BME280 boards want VCC on 3.3 V and some silk says 3.3 V but the board has a regulator. Either way, 3.3 V is the safe answer.

Path 1: ESPHome

Install

ESPHome lives inside Home Assistant. Open your HA UI and go to Settings >> Add-ons >> Add-on Store >> search ESPHome Device Builder >> Install >> Start. That is the whole install. If you run HA Container (not HAOS), you instead run the ESPHome standalone tool in Docker; the config format is identical.

The config

In the ESPHome builder: + NEW DEVICE >> name it desk-sensor >> pick ESP32 >> skip the Wi-Fi step (paste your own). You get a YAML file. Replace the sensor section with this:

esphome:
  name: desk-sensor

esp32:
  board: esp32dev

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

api:
  encryption:
    key: "paste-the-key-esphome-generated"

ota:
  - platform: esphome

i2c:
  sda: 21
  scl: 22
  scan: true

sensor:
  - platform: bme280
    address: 0x76
    temperature:
      name: "Desk temperature"
    pressure:
      name: "Desk pressure"
    humidity:
      name: "Desk humidity"
  - platform: wifi_signal
    name: "Desk WiFi"
    update_interval: 60s

Click Install, pick "Plug into this computer" the first time (the flash happens from the browser over USB, no Arduino IDE involved), and after about two minutes the device appears in Home Assistant's device list with three sensor entities. No discovery config, no MQTT broker, no code. That is the ESPHome pitch and it is real.

Path 2: native firmware with MQTT discovery

Install

If you want your own loop instead, install the libraries in the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search PubSubClient by Nick O'Leary and Adafruit BME280 Library. Install both.

The code

The trick is the discovery message. Home Assistant listens for retain-flagged JSON on homeassistant/sensor/<node_id>/<object_id>/config describing the entity. Publish that once at boot and HA builds the entity for you. Then publish state on the topic the config points to.

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

const char* WIFI_SSID = "your-wifi-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* MQTT_HOST = "192.168.1.50";   // the Pi running Mosquitto

WiFiClient net;
PubSubClient mqtt(net);
Adafruit_BME280 bme;

char stateTopic[] = "home/desk-sensor/state";
char cfgTemplate[] =
  "{\"device_class\":\"temperature\",\"unit_of_measurement\":\"°C\","
  "\"value_template\":\"{{ value_json.temperature }}\","
  "\"state_topic\":\"home/desk-sensor/state\","
  "\"unique_id\":\"desk_sensor_temp\","
  "\"device\":{\"name\":\"Desk sensor\",\"identifiers\":[\"desk-sensor\"]}}";

void publishDiscovery() {
  String topic = "homeassistant/sensor/desk-sensor/temperature/config";
  mqtt.publish(topic.c_str(), cfgTemplate, true);   // retain = true, HA remembers it
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  if (!bme.begin(0x76)) {
    Serial.println("BME280 not found. Check wiring/address.");
    while (true) delay(100);
  }
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  mqtt.setServer(MQTT_HOST, 1883);
  while (!mqtt.connect("desk-sensor")) { delay(300); }
  publishDiscovery();
}

void loop() {
  mqtt.loop();
  static uint32_t last = 0;
  if (millis() - last < 30000) return;   // every 30 s
  last = millis();
  float t = bme.readTemperature();
  float h = bme.readHumidity();
  float p = bme.readPressure() / 100.0F;
  char payload[96];
  snprintf(payload, sizeof(payload),
           "{\"temperature\":%.2f,\"humidity\":%.2f,\"pressure\":%.2f}",
           t, h, p);
  mqtt.publish(stateTopic, payload);
  Serial.println(payload);
}

Upload, then in Home Assistant: Settings >> Devices & services >> the MQTT integration shows "Desk sensor" with a temperature entity. If HA was already running while you published, the entity appears within seconds because the message was retained.

Keep the discovery message small and retain it. A lost retained discovery message after a broker restart is the number-one "my sensor vanished from HA" cause. Republishing at boot (this sketch does) fixes it.

Which path when

  • ESPHome when the device is sensors and switches, when you want OTA updates from the HA UI, and when you would rather edit YAML than C++.
  • Native MQTT when you need timing-sensitive code, custom protocols, or you already have a working sketch and just want it in HA.

What you learned

  • ESPHome turns the firmware into YAML and flashes it from the browser; HA picks up every entity automatically.
  • Native MQTT needs one retained discovery JSON per entity, then HA builds the sensor itself.
  • Same wiring, same I2C pins for both. The paths differ only in the firmware.
  • Pick one path per device; flashing one replaces the other.

When something breaks

  • ESPHome flash fails at "Connecting..." Hold the BOOT button on the devkit while the flash starts, release when it connects. The classic CP2102 driver issue on Windows shows up as the COM port never appearing at all.
  • The MQTT sensor never appears in HA. The discovery message needs retain=true and HA's MQTT integration needs to be enabled (Settings >> Devices & services >> MQTT). Watch the topic with mosquitto_sub -t 'homeassistant/#' -v to see whether your JSON actually went out.
  • The sensor appears but values show "unknown". The value_template in the config JSON does not match your state payload's keys. If you publish {"temperature":22.1} the template must say {{ value_json.temperature }} exactly.
  • ESPHome device shows "offline" in HA. Wrong API encryption key after regenerating, or the YAML still has a placeholder Wi-Fi secret. The device logs (in the ESPHome builder) say which.
  • Both paths on one device fight each other. They cannot coexist; the flash overwrote whatever was there. Reflash with the path you want and move on.

What to build next

  • Pair this with the MQTT publish-subscribe tutorial if you want to understand the topic structure the discovery messages ride on.
  • The raspberry-pi-home-assistant tutorial covers the HA server side, including the backup you should take before you flash anything.
  • Once sensors are in HA, the ntfy notifications tutorial shows the self-hosted way to get your phone to buzz when a threshold trips.
  • The book IoT with ESP32 bundles the sensor, MQTT, and automation chapters into one project arc.

Chapter 87

ESP32: play audio through the MAX98357A I2S amp

esp32 · 40 min

You have an ESP32 listening with an INMP441 (the I2S microphone tutorial covers the input half). This is the output half: the MAX98357A, a 3-wire-in 3-watt-out class-D amplifier that speaks I2S, the same bus the mic uses. Wire it to a small speaker and the ESP32 can talk: play beeps, spoken prompts, or the "door opened" chime that makes a project feel finished.

The trap: people wire it up, run a test sketch, and hear noise or silence, then spend an evening blaming the code. It is almost never the code. It is the gain pin (floating gain is 9 dB, which clips a quiet signal into garbage), the speaker impedance (4 or 8 ohm, not a headphone), or Wi-Fi traffic colliding with the I2S clock. Check the three hardware things first.

What you need

Needed

Part Qty Why this one
ESP32 dev board 1 The I2S peripheral is built in; no extra DAC needed
MAX98357A breakout (Adafruit 3006 or the blue GY variant) 1 I2S in, 3 W class-D out, works at 5 V with 3.3 V-tolerant inputs
4 or 8 ohm speaker, 3 W max 1 The amp is rated 3 W; a beefier speaker just distorts
Jumper wires 6 I2S three lines, power two

Nice to have

  • Soldering iron and solder (if the breakout's headers are loose)
  • Iron stand and soldering mat
  • Helping hands (holding a header while soldering)
  • Wire stripper (for bare-wire speaker connections)
  • Multimeter (verify 5 V actually reaches Vin before blaming the amp)
  • Anti-static wristband

Wiring

MAX98357A ESP32
Vin 5V (the Vin pin)
GND GND
DIN GPIO 32
BCLK GPIO 33
LRC GPIO 25
Speaker + / − Screw terminals to a 4 or 8 ohm speaker

The INMP441 mic tutorial used GPIO 32/33/25 for input. For output on a second I2S peripheral, pick different pins (e.g. SCK 26, WS 27, SD 35 for the mic while the amp keeps 32/33/25). One I2S peripheral cannot be input and output at the same time, but the ESP32 has two peripherals, so mic and amp coexist fine.

The GAIN pin sets amp gain: tied to GND is 12 dB, floating is 9 dB, tied to Vin is 6 dB, and a 100k resistor from GAIN to Vin gives 15 dB. Floating is fine for testing. If audio is loud-but-crunchy, drop the gain to 6 dB before you rewrite any code.

Install

In the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search ESP8266Audio by Earle Philhower. Install it. Despite the name it supports the ESP32 and provides WAV and MP3 players that output to I2S. Nothing else to install; the I2S driver ships with the ESP32 Arduino core.

The code

First the direct approach: a beep and a WAV blob from program memory. This is the talking-device pattern in its smallest form: the ESP32 holds a short sound in flash and pushes it out on cue.

#include <driver/i2s.h>
#include "sound.h"   // a WAV converted to a byte array (see below)

#define I2S_DIN   32
#define I2S_BCLK  33
#define I2S_LRC   25

void i2sInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = 22050,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = true,
  };
  i2s_pin_config_t pins = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S_BCLK,
    .ws_io_num = I2S_LRC,
    .data_out_num = I2S_DIN,
    .data_in_num = I2S_PIN_NO_CHANGE,   // output only
  };
  i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pins);
}

void playWav() {
  // Skip the 44-byte WAV header in the array
  const uint8_t* data = sound_data + 44;
  size_t bytes = sizeof(sound_data) - 44;
  size_t written;
  i2s_write(I2S_NUM_0, data, bytes, &written, portMAX_DELAY);
}

void beep(uint32_t freq, uint32_t ms) {
  const int rate = 22050;
  int16_t sample;
  for (uint32_t i = 0; i < (rate / 1000) * ms; i++) {
    sample = (int16_t)(8000.0 * sin(2.0 * PI * freq * i / rate));
    i2s_write(I2S_NUM_0, &sample, 2, nullptr, portMAX_DELAY);
  }
}

void setup() {
  Serial.begin(115200);
  i2sInit();
  Serial.println("Beep in 1 second...");
  delay(1000);
  beep(880, 300);
  delay(500);
  playWav();          // your sound: a chime, a voice line, whatever
  Serial.println("Done.");
}

void loop() {}

To make sound.h: take a small mono 16-bit 22050 Hz WAV (Audacity does the conversion), then run:

xxd -i chime.wav > sound.h

xxd -i emits the array plus a length symbol; either rename them to match the sketch or adjust the sketch to the generated names.

The streaming version

For anything longer than a beep you do not want the audio in flash. ESP8266Audio plays an MP3 stream straight out through the same three wires:

#include <WiFi.h>
#include "AudioFileSourceHTTPStream.h"
#include "AudioGeneratorMP3.h"
#include "AudioOutputI2S.h"

AudioGeneratorMP3* mp3;
AudioFileSourceHTTPStream* file;
AudioOutputI2S* out;

void setup() {
  Serial.begin(115200);
  WiFi.begin("your-wifi-ssid", "your-wifi-password");
  while (WiFi.status() != WL_CONNECTED) delay(300);

  file = new AudioFileSourceHTTPStream("http://your-server:8000/stream");
  out = new AudioOutputI2S();
  out->SetPinout(33, 25, 32);   // BCLK, LRC, DIN
  out->SetGain(0.5);            // 0.0 to 1.0; start low, your ears matter
  mp3 = new AudioGeneratorMP3();
  mp3->begin(file, out);
}

void loop() {
  if (mp3->isRunning()) {
    if (!mp3->loop()) {
      mp3->stop();
      Serial.println("Stream ended.");
    }
  }
}

That is the whole player: a URL in, sound out. Point it at your own stream (e.g. a self-hosted Icecast server or an MP3 served by the web server tutorial) and you have a talking device that never touches a cloud service.

What you learned

  • The MAX98357A takes standard I2S and amplifies it to speaker level; the ESP32 generates I2S with its built-in peripheral.
  • Output is the same driver pattern as input, with I2S_MODE_TX and a data_out pin instead of data_in.
  • Short sounds live in flash as byte arrays (xxd -i converts a WAV to C); long audio streams over HTTP.
  • Gain pin floating is 9 dB; drop to 6 dB if it clips.

When something breaks

  • Silence. Check in order: 5 V on Vin, speaker actually connected, DIN/BCLK/LRC on the pins the code says. Then check GAIN: a stray jumper tying it to GND is 12 dB into a tiny speaker, which distorts into a buzz that reads as broken.
  • Loud static or robotic noise. Sample rate mismatch. The WAV was recorded at 22050 and the driver runs at 44100 (or the reverse). These must match the WAV header exactly.
  • Plays fine, then stutters when Wi-Fi is busy. DMA buffers too small for the network load. Bump dma_buf_count to 16 or lower the stream bitrate. This is the classic I2S-plus-Wi-Fi collision.
  • MP3 plays nothing but the beep works. The stream URL returns an error page, not audio. Test the URL in a browser first; MP3 decoders fail silent on HTML.
  • Mic and amp both wired, neither works. You pointed both at the same I2S peripheral (the INMP441 sketch uses I2S_NUM_0 too). Give the amp I2S_NUM_1 with different GPIOs, or unplug the mic while you test.

What to build next

  • Pair this with the INMP441 microphone tutorial and you have a walkie-talkie: mic pushes audio over the network, amp plays it.
  • The wake word detection tutorial plus this amp is a self-hosted doorbell that greets by name.
  • Add ntfy notifications with a spoken alert instead of a phone buzz (e.g. play a chime through the amp, then push the details to the phone).
  • The book IoT with ESP32 bundles the mic, amp, and streaming into one door-station project arc.

Chapter 88

ESP32-CAM: photo delivery patterns over ntfy (attach, caption, thumbnail)

esp32 · 35 min

The photo-on-motion tutorial sends one kind of ntfy message: the raw JPEG body that shows up as an attachment. That is the workhorse, but ntfy actually has three useful delivery shapes for a camera photo, and they behave differently on the phone (e.g. the attachment fills the notification, the thumbnail sits inline while the full image lives behind a tap, and the captioned version lets one POST carry two messages at once).

The trap I hit: I set X-Attach-URL pointing at the ESP32-CAM's own web server and the notification arrived with no image. The phone had to fetch the JPEG from the camera, the camera was on the LAN, the phone was on cellular, and nothing could reach anything. ntfy has two attachment paths (bytes in the POST body vs a URL the server or client fetches) and picking the wrong one fails silently.

What you need

Needed

  • ESP32-CAM board (AI-Thinker, about $10)
  • FTDI USB-serial adapter for programming
  • 5V supply rated 500 mA or better
  • The ntfy app on your phone, subscribed to a topic (e.g.
  • Optionally, a self-hosted ntfy server on your LAN (the self-hosted

Nice to have

  • A soldering iron and solder (only if you solder the header pins yourself)
  • Helping hands or a vise to hold the board while you work
  • An anti-static wristband (cheap insurance for the ESP32's pins)

Wiring

Same FTDI dance as every ESP32-CAM post:

FTDI ESP32-CAM
5V 5V
GND GND
TX U0R (GPIO 3)
RX U0T (GPIO 1)
(n/a) GPIO 0 to GND during power-up (download mode)

Ground GPIO 0, power the board, upload, remove the jumper, press reset. No other wiring: this post is about the delivery layer, so the trigger side is a plain loop() timer. Wire a PIR on GPIO 13 per the photo-on-motion tutorial when you want the real trigger.

Install

No new libraries. The ESP32 Arduino core's HTTPClient.h does everything here. Board selection: Tools >> Board >> ESP32 Arduino >> AI Thinker ESP32-CAM.

The code

One sketch, three send functions, one per pattern:

#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>

// AI-Thinker pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y2_GPIO_NUM     5
#define Y3_GPIO_NUM    18
#define Y4_GPIO_NUM    19
#define Y5_GPIO_NUM    21
#define Y6_GPIO_NUM    36
#define Y7_GPIO_NUM    39
#define Y8_GPIO_NUM    34
#define Y9_GPIO_NUM    35
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

const char* NTFY_URL = "https://ntfy.sh/porch-cam-m3z8";
// Self-hosted: http://192.168.1.50:25800/porch-cam-m3z8

bool cameraReady = false;

void setup() {
  Serial.begin(115200);

  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_VGA;
  config.jpeg_quality = 12;
  config.fb_count = 1;

  cameraReady = (esp_camera_init(&config) == ESP_OK);
  Serial.println(cameraReady ? "camera ok" : "camera FAILED (check power)");

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nready");
}

camera_fb_t* capture() {
  if (!cameraReady) return nullptr;
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) Serial.println("capture failed");
  return fb;
}

// Pattern 1: the raw JPEG body (the photo-on-motion workhorse)
void sendAttach() {
  camera_fb_t *fb = capture();
  if (!fb) return;
  HTTPClient http;
  http.begin(wc, NTFY_URL);
  http.addHeader("Content-Type", "image/jpeg");
  http.addHeader("X-Title", "Porch: photo attached");
  int code = http.POST(String((const char*)fb->buf), fb->len);
  Serial.printf("attach: %d\n", code);
  http.end();
  esp_camera_fb_return(fb);
}

// Pattern 2: captioned image (text body + X-Filename makes it an
// attachment while the message text rides along)
void sendCaptioned(String caption) {
  camera_fb_t *fb = capture();
  if (!fb) return;
  HTTPClient http;
  http.begin(wc, NTFY_URL);
  http.addHeader("Content-Type", "image/jpeg");
  http.addHeader("X-Title", "Porch cam");
  http.addHeader("X-Message", caption);      // the text under the image
  http.addHeader("X-Filename", "porch.jpg"); // turns the body into an attachment
  int code = http.POST(String((const char*)fb->buf), fb->len);
  Serial.printf("captioned: %d\n", code);
  http.end();
  esp_camera_fb_return(fb);
}

// Pattern 3: thumbnail + link. Send a small downscaled JPEG in the
// body, and let the notification carry a URL to the full frame.
void sendThumbAndLink() {
  camera_fb_t *fb = capture();
  if (!fb) return;

  // Serve the full frame yourself (snapshot server, SD card + web
  // server, or any endpoint you own); here we just reference one:
  String fullUrl = "http://192.168.1.77/porch-latest.jpg";

  HTTPClient http;
  http.begin(wc, NTFY_URL);
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("X-Title", "Porch cam");
  http.addHeader("X-Click", fullUrl);        // tapping opens the full photo
  http.addHeader("X-Icon", "https://your-server.local/cam-icon.png");
  int code = http.POST("Tap for the full photo");
  Serial.printf("thumb+link: %d\n", code);
  http.end();

  // Then push the full JPEG wherever fullUrl points (e.g. POST it to
  // a tiny receiver on your LAN, or write to SD if that receiver is
  // the camera's own SD web server).
  esp_camera_fb_return(fb);
}

WiFiClient wc;   // shared client for HTTPClient::begin(wc, url)

void loop() {
  sendAttach();
  delay(20000);
  sendCaptioned("Motion test at " + String(millis() / 1000) + "s");
  delay(20000);
  sendThumbAndLink();
  delay(60000);
}

Note the HTTPClient http; http.begin(wc, NTFY_URL) signature: the shared WiFiClient overload. On the ESP32 Arduino core 2.x, the no-argument begin(url) form leaks sockets when called repeatedly (each call opens a new one); passing the shared client closes each connection properly.

Which pattern when

Pattern What arrives Use it for
Raw attach full image in the notification events you judge by looking
Captioned image plus text events where the why matters (e.g. "back door, 3 people")
Thumb + link small card, tap for full bandwidth-limited or archived photos

The raw attach is the default. Captioned when the event carries metadata worth reading (a sensor value, a count). Thumb + link when the photos are big, the network is slow, or you are sending many and want the phone to stay snappy.

Attachment payloads over the public ntfy.sh count against its per-message and per-visitor caches. VGA at quality 12 is 20-40 KB and fine. UXGA at quality 10 can be 300 KB and gets you rate-limited faster; self-host before you raise frame sizes.

The two attachment paths, and when the URL one works

ntfy attachments have two distinct mechanics:

  • Body bytes: the JPEG travels inside your POST. Works everywhere, both ntfy.sh and self-hosted, and the image is delivered even if the camera goes offline a second later.
  • X-Attach-URL: the server (or your phone) fetches the JPEG from a URL you name. Only works if that URL is reachable from wherever the fetch happens (e.g. your LAN-only camera URL is invisible from cellular, so the notification arrives with no image and no error).

The URL path earns its keep when you already run a server that holds the full image (e.g. the camera's own SD-card web server, or a receiver box on a VPS). Body bytes win everywhere else.

What you learned

  • One POST, three shapes: raw attachment, captioned attachment, and link card, all chosen by headers, not by different endpoints.
  • X-Filename plus an image Content-Type is what makes the body an attachment with a caption; X-Click is what makes a message tappable.
  • The URL-attachment path needs a URL reachable from the phone, not just from the camera.

When something breaks

  • Notification arrives with no image: you sent body bytes with a wrong Content-Type (e.g. text/plain instead of image/jpeg), or you used X-Attach-URL with a LAN-only URL while on cellular. Check both.
  • "Camera init failed": the power supply, as with every ESP32-CAM post. 5V, 500 mA+, short thick cable.
  • POST returns 429: the public server rate limit. VGA photos at reasonable intervals are fine; a burst of UXGA attachments is not. Self-host.
  • Image arrives as garbage pixels: power brownout mid-capture or jpeg_quality below 10. Same fixes as the streaming tutorial.
  • Caption shows but image does not: X-Filename was missing, so the body stayed a plain text message that happens to be bytes. The filename header is what flips the body to attachment mode.

What to build next

  • The photo-on-motion tutorial is the PIR-triggered baseline this post varies the delivery for.
  • The streaming tutorial serves the live view that the thumb + link pattern can deep-link into.
  • The ntfy tutorial covers self-hosting, priorities, and the MQTT bridge for two-way topics.
  • The book IoT with ESP32 bundles the camera tutorials.

Chapter 89

ESP32: push readings into InfluxDB over HTTP

esp32 · 30 min

You have InfluxDB and Grafana running on the Pi (the raspberry-pi-influxdb-grafana tutorial covers the server side, the token, and the bucket named home). This tutorial is the device side: an ESP32 that pushes BME280 readings into that InfluxDB with a plain HTTP POST. No MQTT, no broker process to babysit, no client library. Line protocol over HTTP is one of the simplest thing-to-database writes in all of IoT.

The trap: the write fails with HTTP 401 or 404 and people assume the ESP32 code is wrong. It is almost always the URL. InfluxDB 2.x wants /api/v2/write?org=...&bucket=..., with a Token header, and the org and bucket must match what you typed during server setup exactly (e.g. org=home when you created the org as home). One typo and you spend an hour recompiling for nothing.

What you need

Needed

  • ESP32 dev board
  • A BME280 module for the example (any sensor works; swap the read
  • A Pi (or any Linux box) with InfluxDB 2.x running, reachable on
  • The API token from the InfluxDB setup (the
  • 4 jumper wires and a breadboard

Nice to have

  • A soldering iron and solder (only if you solder the header pins yourself)
  • Helping hands or a vise to hold the board while you work
  • An anti-static wristband (cheap insurance for the ESP32's pins)

Wiring

BME280 ESP32
VCC 3.3V
GND GND
SDA GPIO 21
SCL GPIO 22

The stock ESP32 I2C pins. Nothing exotic here; the interesting part of this tutorial is all in the HTTP write.

Install

In the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search Adafruit BME280 Library (pulls in the Adafruit Unified Sensor dependency; install that too when the IDE asks). The HTTP client is built into the ESP32 core, so there is no MQTT broker, no extra protocol library, nothing else.

The code

Line protocol is one text line per measurement: measurement,tag=value field=value timestamp. Without a timestamp the server stamps arrival time, which is what you want 95% of the time.

#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

const char* WIFI_SSID   = "your-wifi-ssid";
const char* WIFI_PASS   = "your-wifi-password";
const char* INFLUX_HOST = "192.168.1.50";        // the Pi
const char* INFLUX_ORG  = "home";
const char* INFLUX_BUCKET = "home";
const char* INFLUX_TOKEN  = "your-long-influxdb-token";

Adafruit_BME280 bme;

String writeUrl() {
  return String("http://") + INFLUX_HOST +
         ":8086/api/v2/write?org=" + INFLUX_ORG +
         "&bucket=" + INFLUX_BUCKET + "&precision=s";
}

bool influxWrite(const String& line) {
  HTTPClient http;
  http.begin(writeUrl());
  http.addHeader("Content-Type", "text/plain; charset=utf-8");
  http.addHeader("Authorization", String("Token ") + INFLUX_TOKEN);
  int code = http.POST(line);
  http.end();
  if (code != 204) {          // 204 No Content is Influx's success code
    Serial.printf("Write failed, HTTP %d\n", code);
    return false;
  }
  return true;
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  if (!bme.begin(0x76)) {
    Serial.println("BME280 not found. Check wiring/address.");
    while (true) delay(100);
  }
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  static uint32_t last = 0;
  if (millis() - last < 60000) return;   // one reading per minute
  last = millis();

  float t = bme.readTemperature();
  float h = bme.readHumidity();
  float p = bme.readPressure() / 100.0F;

  // line protocol: measurement, tags, then fields, space-separated
  String line = String("home_sensor,room=desk,device=esp32-1 ") +
    "temperature=" + String(t, 2) + "," +
    "humidity="    + String(h, 2) + "," +
    "pressure="    + String(p, 2);

  if (influxWrite(line)) {
    Serial.println("Wrote: " + line);
  }
}

Upload, then check it landed. In the Influx UI (http://pi-ip:8086): Explore >> pick the bucket home >> you should see the home_sensor measurement with three fields. Or just query the API:

curl -G "http://pi-ip:8086/api/v2/query?org=home" \
  -H "Authorization: Token your-long-influxdb-token" \
  -H "Accept: application/csv" \
  --data-urlencode 'from(bucket:"home") |> range(start: -10m)'

HTTP, not HTTPS, on a trusted LAN is a normal choice here. If your Pi is reachable from the wider network, do two things: give the token read-write scope only, and consider putting the Pi behind WireGuard instead of port-forwarding (the wireguard tutorial covers that).

Batch writes (the version I actually run)

One HTTP request per reading is fine at once-a-minute rates. When a device reports every few seconds, buffer lines in RAM and flush a batch. The endpoint accepts multiple lines separated by \n in one POST, which cuts the request count by the batch size.

String batch = "";
int pending = 0;

void queueReading(float t, float h, float p) {
  batch += String("home_sensor,room=desk ") +
           "temperature=" + String(t, 2) + "," +
           "humidity=" + String(h, 2) + "\n";
  if (++pending >= 10) {          // flush every 10 readings
    HTTPClient http;
    http.begin(writeUrl());
    http.addHeader("Authorization", String("Token ") + INFLUX_TOKEN);
    http.POST(batch);
    http.end();
    batch = "";
    pending = 0;
  }
}

Keep batches under about 5 KB and flush before deep sleep (unwritten lines die with the RAM).

What you learned

  • Line protocol is measurement,tags fields: one text line per point, POSTed to /api/v2/write.
  • The Token header carries the API token; 204 means success.
  • Tags go in the line and make Grafana filtering free (e.g. room=desk lets one dashboard show every room).
  • Batch multiple lines in one POST to cut request overhead.

When something breaks

  • HTTP 401. The token is wrong, missing the Token prefix, or scoped to a different org. The header must be exactly Authorization: Token <value>.
  • HTTP 404 or 422. The org or bucket name in the URL does not match the server. Re-check against Explore in the Influx UI (e.g. org=home fails if the org is actually myhome).
  • HTTP 429 or writes slow down over time. You are writing too fast for the Pi's SD card. Raise the interval to 30 s or more, batch, and set a retention policy so the card is not writing forever.
  • Data appears but Grafana shows nothing. The Grafana data source token usually has read scope while you created it before the bucket. Check the data source's token, not the ESP32's.
  • Readings jump around wildly. You put the reading value in a tag instead of a field. Tags are strings and indexes; fields are the numbers. Swap them and the graphs behave.

What to build next

  • Read the raspberry-pi-influxdb-grafana tutorial end to end if you have not yet: this device write is half of that stack.
  • The MQTT publish-subscribe tutorial is the alternative feed: MQTT broker in the middle when many devices write to many consumers at once.
  • The web server sensor dashboard tutorial pairs with this as the device's own local dashboard while Influx handles history.
  • The book IoT with ESP32 walks the full device-to-dashboard arc with retention and alerting included.

Chapter 90

ESP32: send IR codes with an IR LED

esp32 · 25 min

The IR receiver tutorial taught the input half: point any remote at a VS1838B and read button presses as hex values. This is the output half: an IR LED driven by the ESP32 replays those codes without the original remote in the room. The result is an IR blaster you can trigger from anything (HTTP, MQTT, a schedule), which turns "the AC remote is on the couch again" into "the AC turned off at midnight because a script said so."

The trap: people wire an IR LED straight to a GPIO, aim it at the TV, and get nothing at three meters. A bare GPIO cannot push enough current into an LED to reach across a room. The fix is a transistor, and the second fix is remembering IR needs line of sight: light does not go through the couch cushion or around the corner.

What you need

Needed

  • ESP32 dev board
  • 2N2222 or BC337 NPN transistor (the range fix; do not skip it)
  • 220 ohm resistor (LED current limit) and 1k ohm resistor
  • IR receiver module (VS1838B, from the receiver tutorial) to capture
  • Jumper wires and a breadboard

Nice to have

  • 940 nm IR LED (a standard 5 mm one; the same kind inside remotes)

Wiring

Component Connect to
IR LED anode (long leg) Transistor collector, through the 220 ohm resistor from 5V
IR LED cathode (short leg) 5V rail, through the 220 ohm resistor
Transistor collector LED cathode side
Transistor emitter GND
Transistor base GPIO 12, through the 1k ohm resistor
VS1838B VCC / GND / OUT 3.3V / GND / GPIO 4

That LED drive layout drives the LED from 5 V with the transistor switching it: much brighter than a GPIO driving the LED directly, which is what gives the three-meter range.

Point the LED the same way the original remote points: at the device's IR window. Tape it in place for a permanent install. Most failures in the field are aiming, not electronics.

Install

In the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search IRremoteESP8266 by David Conran et al. Install it. It sends and receives, so this tutorial and the receiver tutorial use the same library.

The code

Step 1: capture the codes you need

Run the receiver sketch from the IR receiver tutorial, press each button you care about, and write down the protocol and hex value (e.g. NEC 0x20DF10EF for power on one LG TV). This step is device-specific and there is no shortcut: remotes differ.

Step 2: send them

#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <WiFi.h>
#include <WebServer.h>

const uint16_t IR_PIN = 12;      // transistor base via 1k
IRsend irsend(IR_PIN);

WebServer server(80);

// Values captured with the receiver tutorial's sketch, one per button
const uint64_t TV_POWER   = 0x20DF10EF;   // NEC
const uint64_t TV_VOL_UP  = 0x20DF906F;
const uint64_t AC_OFF     = 0x8F710BE;    // example 28-bit AC code

void setup() {
  Serial.begin(115200);
  irsend.begin();
  WiFi.begin("your-wifi-ssid", "your-wifi-password");
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  Serial.println(WiFi.localIP());

  server.on("/tv/power", []() {
    irsend.sendNEC(TV_POWER);
    server.send(200, "text/plain", "sent tv power");
  });
  server.on("/tv/volup", []() {
    irsend.sendNEC(TV_VOL_UP, 30);   // repeat 30x = hold the button down
    server.send(200, "text/plain", "sent vol up x30");
  });
  server.on("/ac/off", []() {
    irsend.sendNEC(AC_OFF);
    server.send(200, "text/plain", "sent ac off");
  });
  server.begin();
}

void loop() {
  server.handleClient();
}

Upload, then from any device on the network:

curl http://esp32-ip/tv/power

and the TV turns off. Any trigger you already have can now call that URL (e.g. the MQTT tutorial's flow, an ntfy button, a cron job on the Pi at midnight).

AC units are a different animal

TV remotes send one command per button. AC remotes send the entire state (temp, fan, mode, swing) as one long blob every press. If you replay an old AC code after someone changed the temperature with the physical remote, you revert the whole unit. The library has protocol-specific sender classes for the common AC brands (e.g. IRPanasonicAc, IRMitsubishiAC, IRDaikinESP) that let you set state fields properly:

#include <ir_Panasonic.h>

IRPanasonicAc panasonic;
panasonic.begin(IR_PIN);
panasonic.setModel(kPanasonicDke);
panasonic.on();
panasonic.setTemp(22);
panasonic.setFan(kPanasonicFanAuto);
panasonic.send();   // transmits the complete state

Find your brand in the library's examples folder (each supported AC protocol has a sender example with a printState() that decodes what you captured, which is the honest way to verify a model guess).

What you learned

  • An IR LED needs transistor drive from 5 V to get real range; a bare GPIO reaches about a meter.
  • IRremoteESP8266 sends with sendNEC(code) and repeats with a count (e.g. 30 repeats is a held volume button).
  • Capture codes with the receiver tutorial first; every remote's values are its own.
  • AC units send whole-state blobs; use the brand sender classes instead of replaying raw codes.

When something breaks

  • Nothing happens under three meters. The LED is GPIO-driven instead of transistor-driven, or the resistor is 1k instead of 220 ohm. Rebuild the drive circuit; this is the number one cause.
  • Works at close range, not across the room. Aiming, or the LED is behind the device's own IR window glare shield. Move the LED, test with a phone camera (the LED shows as a purple flash on camera; a good blaster is clearly visible).
  • The device responds to some commands, not others. Your captured hex came from a repeat frame or a different protocol than you assumed. Recapture that button, note the protocol typeToString reports, and send with the matching send* function.
  • The AC toggles the wrong mode. You replayed a stale full-state code. Switch to the brand-specific AC sender class and set state fields explicitly.
  • The web endpoints return nothing. The IR burst blocks the CPU for ~100 ms per repeat burst, so the server answers late; the default HTTP timeout on some clients is shorter. Send repeats asynchronously or lower the count.

What to build next

  • The IR receiver tutorial is the capture half of this; run both sketches from one board to make a learnable universal blaster.
  • Wire this into the MQTT publish-subscribe tutorial so any topic (e.g. home/ir/send) triggers a code instead of an HTTP call.
  • The home sensor hub tutorial plus a blaster is a whole-room controller: sense and act from one board.
  • The book IoT with ESP32 bundles receiver and transmitter into an IR hub project with a web UI.

Chapter 91

ESP32: detect motion through walls with the RCWL-0516 radar

esp32 · 30 min

The Arduino RCWL-0516 tutorial covered the sensor itself: a $2 Doppler radar that fires a 3.18 GHz wave through plastic and drywall and triggers on anything that moves. This tutorial is the same sensor on the ESP32, and the ESP32 is what makes the pair interesting: the radar's trigger is now a network event. Motion in the garage becomes an ntfy alert on your phone, a wake-up interrupt from deep sleep, or a line in a log with a timestamp. The sensor part takes three wires and five minutes; the rest of this tutorial is the ESP32-side patterns around it.

The trap is the same trap as the Arduino version, and I will name it anyway because it costs people a weekend: microwave radar does not care what is moving. It sees through the wall you mounted it behind, and through the cabinet, and into the room where your ceiling fan lives (e.g. my first hallway install triggered every time the bathroom exhaust fan kicked on two rooms away). Placement is the tuning process. The code will be correct long before the install is.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (WROOM-32 devkit) 1 reads the OUT pin, adds Wi-Fi reporting and deep sleep $10
RCWL-0516 microwave radar module 1 the motion sensor: 3.18 GHz Doppler radar $2
Jumper wires (3) 3 VIN, GND, OUT $1
Breadboard (optional but handy) 1 bench testing before the permanent mount $3

Why the ESP32 over the Uno for this sensor: the RCWL-0516 draws up to about 100 mA while transmitting, which is fine on USB power, and the deep-sleep current of the ESP32 (about 10 microamps) is what makes a battery-powered radar practical. The Arduino path can sleep, but it cannot wake, report over Wi-Fi, and go back to sleep in one piece of hardware.

Nice to have

  • Plastic project enclosure: the radar fires straight through most plastics, so the finished build hides inside a box. Metal is the one material that kills it.
  • Multimeter: confirm 5 V at VIN before blaming anything else.
  • Magnifying goggles: the sensitivity pot and the C-T solder pad are tiny silkscreen markings.
  • Soldering iron + solder: if you move the module to a permanent mount with cut-to-length leads.
  • Wire stripper: for those leads.
  • Anti-static wristband: cheap insurance while handling a bare module.

Wiring

Three wires. The OUT pin drives about 3.3 V when it detects motion, which the ESP32 reads as HIGH with no level shifting.

RCWL-0516 ESP32
VIN VIN or 5V pin
GND GND
OUT GPIO 4

The module runs on 5 V but its output swings to 3.3 V, so it is directly ESP32-safe. Do not power it from the 3.3 V pin: it will sort of work at reduced range and you will spend an afternoon chasing a phantom sensitivity problem.

Keep the radar module at least 30 cm away from the ESP32 board itself if you can. The dev board's own switching regulator generates just enough electrical noise to show up as occasional false triggers at close range.

Install

Nothing to install. Like the Arduino version, this is a plain digital input, and there is no library worth fetching from Arduino IDE >> Sketch >> Include Library >> Manage Libraries. The whole hardware interface is digitalRead(4). The Wi-Fi and ntfy code uses libraries already in the ESP32 core.

The code

Rising-edge detection with re-arm, then a Wi-Fi report. The radar holds OUT HIGH for about 2 seconds after motion stops (its fixed retrigger window), so the sketch waits for the falling edge before it will report a second event. Same pattern as the Arduino tutorial, plus the network half.

#include <WiFi.h>
#include <HTTPClient.h>

const int RADAR_OUT = 4;

void setup() {
  Serial.begin(115200);
  pinMode(RADAR_OUT, INPUT);

  WiFi.begin("your-wifi-ssid", "your-wifi-password");
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  Serial.println(WiFi.localIP());
}

void reportMotion() {
  HTTPClient http;
  http.begin("http://ntfy.sh/your-secret-topic");
  http.addHeader("Content-Type", "text/plain");
  http.POST("Radar: motion detected");
  http.end();
}

void loop() {
  static int state = 0;
  static unsigned long lastTrigger = 0;

  if (digitalRead(RADAR_OUT) == HIGH) {
    if (state == 0) {                      // rising edge: new event
      Serial.print("MOTION, ");
      Serial.print((millis() - lastTrigger) / 1000);
      Serial.println(" s since previous");
      lastTrigger = millis();
      state = 1;
      reportMotion();
    }
  } else {
    state = 0;                             // falling edge: re-armed
  }
  delay(50);
}

Upload it, open the Serial Monitor at 115200, and walk through the room. Each crossing prints a MOTION line and pushes to your ntfy topic (set up in the ntfy notifications tutorial, or use any topic name and subscribe from your phone). Swap the ntfy POST for an MQTT publish if you already run a broker; the edge-detect loop above stays identical either way.

Mounting and tuning

Two physical adjustments, same as the Arduino build:

  1. Sensitivity pot (the one near the antenna side of the board): quarter-turn steps. Walk away until it stops triggering, then one quarter-turn back up. Full clockwise will reach through an interior wall, which you want exactly when you want it and never otherwise.
  2. Placement: the beam is wide and blind to material. Point it at what you want watched and let walls block the rest. Keep it away from fans, HVAC vents moving curtains, and anything on a motor (a fridge compressor triggers it every cycle).

The ESP32-specific placement note: this module is a natural fit for "inside the enclosure" builds, because Wi-Fi works through plastic too. Radar in a box by the garage door, antenna pointed through it, one cable for power.

What you learned

  • Doppler radar detects motion, not heat, and it does it through drywall and plastic: the reflection's frequency shift is the signal.
  • The module holds OUT HIGH about 2 seconds after motion stops, so software needs falling-edge re-arm to count events correctly.
  • On the ESP32 the same three-wire sensor becomes a network citizen: one digitalRead plus an HTTP POST is a whole alerting system.

When something breaks

  • Triggers constantly with nobody moving: fans, curtain-moving vents, compressors, or the sensitivity pot is too high. Drop it a quarter-turn and re-test, then hunt moving objects in the beam path. (The Arduino tutorial's tuning section goes deeper here.)
  • Never triggers: measure 5 V at VIN first, then check that the component side faces the room, then accept that Doppler needs real velocity (a person standing perfectly still is invisible to this sensor, by physics, not by bug).
  • ntfy never arrives but the Serial Monitor reports motion: the POST is failing, not the radar. Print http.HTTPCode() and check Wi-Fi signal strength; a radar in a metal basement is also a Wi-Fi-less ESP32 in a metal basement.
  • Random triggers from the board itself: the dev board's 3.3 V regulator noise at close range. Add 30 cm of separation or a 100 uF capacitor across the module's power pins.
  • Works on the bench, dies in the enclosure: the enclosure is metal. Radar cannot leave a Faraday cage. Plastic only.

What to build next

  • The ESP32 deep sleep tutorial plus this sensor is the battery-powered motion beacon: the radar is wired to the wake pin, the ESP32 wakes, reports, and drops back to microamps.
  • The ESP32 SD card datalogging tutorial turns the trigger lines into a timestamped motion log with no network needed.
  • The ESP32 ntfy notifications tutorial goes deeper on self-hosted alerting, including running the ntfy server on your own Pi.
  • The Arduino RCWL-0516 radar tutorial is the Uno version if your build site has no Wi-Fi worth joining.

The IoT with ESP32 book bundles the sleep, log, and notify tutorials with this sensor into one motion-detection chapter arc.


Chapter 92

ESP32: read high temperatures with a K-type thermocouple and MAX6675

esp32 · 30 min

My espresso machine runs at 93 C at the group head and my soldering iron claims 350 C at the tip. A DS18B20 tops out at 125 C and a thermistor gets you to maybe 150 C before the numbers stop meaning anything. For anything hotter you need a thermocouple, and the cheapest way to read one is the MAX6675.

The trap I hit: I wired the MAX6675 the way every pinout diagram shows, read it in a loop with no delay, and got a number that never changed. Then I waited a quarter second between reads and it worked. The conversion time is 220 milliseconds. Read it faster and you just re-read the same sample, wondering why your process never heats up.

What you need

Needed

  • ESP32 dev board (any WROOM-based board, about $8)
  • MAX6675 breakout module with a K-type thermocouple probe included, about $5 (the common blue module on a small PCB)
  • Jumper wires, female-to-female work best for this module
  • Something actually hot to measure (e.g. a heat gun, a 3D printer nozzle, a mug of just-boiled water for a sanity check)

Nice to have

  • Soldering iron + solder (the probe leads often need a crimp or a solder joint at the terminal block)
  • Soldering mat and iron stand for that work
  • Multimeter with a thermocouple input, so you can cross-check the MAX6675 against a second reading (this caught my first bad probe)
  • Anti-static wristband for handling the module

Wiring

The MAX6675 talks SPI but only listens, so you can share a bus with other SPI devices if you give it its own chip select.

MAX6675 Connects to
VCC ESP32 3.3V
GND ESP32 GND
SCK ESP32 GPIO 18
CS ESP32 GPIO 5
SO (MISO) ESP32 GPIO 19
Thermocouple red wire Module terminal marked +
Thermocouple yellow wire Module terminal marked -

K-type probes use red as negative and yellow as positive in the US color scheme. That is backwards from every other wire convention I know, and it trips everyone exactly once.

Never run thermocouple wire parallel to power cables. The signal at the probe is microvolts and any induced noise shows up as temperature. If the probe wire must cross a mains cable, cross it at 90 degrees.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "MAX6675", install the library by Adafruit.

The code

#include <max6675.h>

#define MAX6675_CS   5
#define MAX6675_SCK  18
#define MAX6675_MISO 19

MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_MISO);

unsigned long lastRead = 0;

void setup() {
  Serial.begin(115200);
  // The MAX6675 needs 220ms after power-up before its first
  // conversion is valid. Do not skip this wait.
  delay(500);
}

void loop() {
  if (millis() - lastRead >= 250) {
    lastRead = millis();
    double celsius = thermocouple.readCelsius();

    if (isnan(celsius)) {
      Serial.println("Probe open circuit or not connected");
    } else {
      Serial.print(celsius, 1);
      Serial.println(" C");
    }
  }
}

That is the whole sensor side. The chip does the cold-junction compensation (it measures the temperature where the probe plugs in and corrects for it) and the ADC conversion internally.

Two details worth knowing:

  1. The MAX6675 resolves to 0.25 C. The datasheet range stops at +1023.75 C, but the practical ceiling with the cheap modules is about 600 C. Past that the module traces drift.
  2. A read returns a floating-point NaN when the probe is disconnected. The chip has a real open-thermocouple flag, which is why the code above checks for it instead of trusting the number.

For a logging build, push the reading out over the network (e.g. the MQTT tutorial on this site publishes a reading every 10 seconds and the loop above slots straight into it).

What you learned

  • A thermocouple is two dissimilar metals making a voltage you cannot read directly. The amplifier chip is not an accessory, it is the sensor.
  • The MAX6675 gives 0.25 C resolution up to about 600 C over three signal wires, with an open-probe flag for free.
  • The conversion takes 220 ms. Read slower than that, not faster.
  • The red wire on a K-type is negative. Nobody believes this until they check.

When something breaks

  • Readings never change. You are polling faster than the 220 ms conversion time. Space your reads at 250 ms or more (the code above does this with millis(), not delay()).
  • Reads a steady 25-30 C no matter what. The probe wires are swapped at the terminal block. K-type red is negative, yellow is positive. Swap them and re-read.
  • Readings go insane above 100 C. The probe tip is touching metal it should not touch (e.g. the pan wall instead of the pan bottom), or the junction at the terminal block is loose. Check the crimp before you blame the chip.
  • NaN on every read. Probe open circuit. Either it is physically disconnected or the thermocouple snapped at the tip (they are thin and they do snap). Keep a spare probe, they are $2.

What to build next

Pair this with the HX711 weight scale tutorial and you have most of a roasting monitor: bean mass and bean temperature on one ESP32, graphed on the sensor dashboard. If you need a wider range or negative temperatures, the MAX31855 breakout is the drop-in upgrade (same three signal wires, 14-bit readings from -200 C up).

The ntfy notifications tutorial is the natural alarm layer: push a phone notification when the reading crosses a threshold, from the same chip.


Chapter 93

ESP32: one web page to replace five remotes (web IR blaster)

esp32 · 45 min

The IR transmitter tutorial built the sending half: an ESP32 with an IR LED that replays captured codes when a URL gets hit. The limitation showed up the second I handed it to my family: nobody is going to bookmark http://192.168.1.42/tv/power or run curl from the couch. This tutorial adds the missing piece, a web page of actual buttons served from the ESP32 itself, and turns the blaster into the one device the whole household can use (my coffee table went from five remotes to one phone screen: TV, soundbar, fan, and two AC units).

The trap: people serve a page, click a button, and get a blank screen, because the ESP32 sent the IR burst and the browser is still waiting for the page to redraw. The fix is knowing which half of the request cycle you are in. The ESP32 serves the page once; the buttons then fire small AJAX calls so the browser never navigates away. Get that split wrong and you will spend an evening rewriting a perfectly working server.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (WROOM-32 devkit) 1 serves the page and fires the IR codes $10
940 nm IR LED (5 mm) 1 the transmitter diode, same kind inside remotes $1
2N2222 or BC337 NPN transistor 1 switches the LED from 5 V; the range fix $0.50
220 ohm resistor 1 limits LED current $0.10
1k ohm resistor 1 limits transistor base current $0.10
VS1838B IR receiver module 1 captures codes from your existing remotes first $1
Jumper wires (6) 6 LED drive circuit and receiver wiring $2
Breadboard 1 prototyping the drive circuit $3

The IR receiver is on this list even though the finished build never uses it, because you need it once, up front, to capture each remote's codes (that capture flow is Step 1 of the IR transmitter tutorial, and there is no shortcut around it: every remote's hex values are its own).

Nice to have

  • Soldering iron + solder: for a permanent install, solder the LED pigtail and transistor leads instead of breadboarding them.
  • Helping hands: holds the LED and transistor while you solder.
  • Iron stand + soldering mat: the standard safety pair.
  • Anti-static wristband: the LED is cheap, the ESP32 behind it is not.
  • Magnifying goggles: telling the LED's long leg from the short one before soldering it backwards.
  • Wire stripper: prepping the pigtail.
  • Multimeter: verify the 5 V rail and LED polarity before blaming the code.
  • Phone camera: shows the IR LED flash as a purple dot on screen, which is the fastest "is it firing at all" test there is.

Wiring

Same drive circuit as the transmitter tutorial, plus the receiver only during the capture step.

Component Connect to
IR LED cathode (short leg) Transistor collector
Transistor emitter GND
Transistor base GPIO 12, through the 1k ohm resistor
5V rail, through 220 ohm resistor IR LED anode side
VS1838B VCC / GND / OUT 3.3V / GND / GPIO 4 (capture step only)

A bare GPIO driving the LED reaches about one meter. The transistor stage is what buys the three-meter room-crossing range. Do not skip it, and do not swap the 220 ohm for a 1k ohm: weak LED current is the number one cause of "works on the desk, not in the living room."

Aim the LED the same way the original remote points: at the device's IR window, taped in place for a permanent install. Most field failures are aiming, not electronics.

Install

In the Arduino IDE: Sketch >> Include Library >> Manage Libraries >> search IRremoteESP8266 by David Conran et al. Install it. It both sends and receives, so capture and replay use the same library. Everything else (the web server, the Wi-Fi) is already in the ESP32 core.

The code

Step 1: capture the codes

Run the receiver sketch from the IR receiver tutorial, press every button you want on the web page, and write down protocol and value for each (e.g. NEC 0x20DF10EF for power on one LG TV). Label them carefully; a code captured from a repeat frame will work in mysterious ways later.

Step 2: the blaster page

#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <WiFi.h>
#include <WebServer.h>

const uint16_t IR_PIN = 12;   // transistor base via 1k
IRsend irsend(IR_PIN);
WebServer server(80);

// Captured with the receiver sketch, one per button you need
const uint64_t TV_POWER  = 0x20DF10EF;   // NEC
const uint64_t TV_VOLUP  = 0x20DF906F;
const uint64_t TV_VOLDN  = 0x20DF708F;
const uint64_t BAR_POWER = 0x00FF02FD;
const uint64_t AC_OFF    = 0x8F710BE;    // example 28-bit AC code

const char PAGE[] PROGMEM = R"=====(
<!DOCTYPE html><html><head><meta name=viewport
  content="width=device-width,initial-scale=1">
<title>Remotes</title>
<style>
body{font-family:sans-serif;text-align:center;margin-top:30px}
button{font-size:1.3em;margin:8px;padding:14px 26px;width:44%}
h3{margin-top:24px}
</style></head><body>
<h1>Remotes</h1>
<h3>TV</h3>
<button onclick="hit('/tv/power')">Power</button>
<button onclick="hit('/tv/volup')">Vol +</button>
<button onclick="hit('/tv/voldn')">Vol -</button>
<h3>Soundbar</h3>
<button onclick="hit('/bar/power')">Power</button>
<h3>AC</h3>
<button onclick="hit('/ac/off')">Off</button>
<script>
function hit(url){
  fetch(url).then(r=>r.text()).then(t=>console.log(t));
}
</script></body></html>
)=====";

void sendAndAck(const char* msg) {
  server.send(200, "text/plain", msg);
}

void setup() {
  Serial.begin(115200);
  irsend.begin();
  WiFi.begin("your-wifi-ssid", "your-wifi-password");
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, []() {
    server.send_P(200, "text/html", PAGE);
  });
  server.on("/tv/power", HTTP_GET, []() {
    irsend.sendNEC(TV_POWER);
    sendAndAck("ok tv power");
  });
  server.on("/tv/volup", HTTP_GET, []() {
    irsend.sendNEC(TV_VOLUP, 30);  // 30 repeats = held button
    sendAndAck("ok vol up x30");
  });
  server.on("/tv/voldn", HTTP_GET, []() {
    irsend.sendNEC(TV_VOLDN, 30);
    sendAndAck("ok vol down x30");
  });
  server.on("/bar/power", HTTP_GET, []() {
    irsend.sendNEC(BAR_POWER);
    sendAndAck("ok soundbar power");
  });
  server.on("/ac/off", HTTP_GET, []() {
    irsend.sendNEC(AC_OFF);
    sendAndAck("ok ac off");
  });
  server.begin();
}

void loop() {
  server.handleClient();
}

Open http://esp32-ip/ from any phone on the network and tap. The fetch() calls hit the same endpoints the transmitter tutorial used with curl, but from a page your family will actually use. Add a bookmark to the home screen and it behaves like an app.

AC units send whole states

A TV remote sends one command per press. An AC remote sends the entire state (temp, fan, mode, swing) as one blob every press, so replaying a captured AC code reverts whatever was changed since. Use the library's brand-specific sender classes (e.g. IRPanasonicAc, IRMitsubishiAC, IRDaikinESP) and set the state fields explicitly. The full pattern with a worked example is in the IR transmitter tutorial's AC section.

What you learned

  • An ESP32 can serve the entire UI for a hardware project from PROGMEM: one string constant, no filesystem, no SD card.
  • The split that makes it feel like an app: one page load, then fetch() calls for each button, so the browser never navigates away.
  • The same endpoints serve both humans (buttons) and machines (curl, MQTT flows, ntfy buttons), because HTTP is the interface either way.

When something breaks

  • Page loads, buttons do nothing, no IR flash. The captured hex came from a repeat frame or the wrong protocol. Recapture that button with the receiver sketch and send with the matching send* function.
  • Phone camera shows no purple flash from the LED. The drive circuit, not the code: check the transistor orientation, the 1k base resistor, and that the LED is not in backwards.
  • Buttons work but the page hangs between presses. The IR burst blocks the CPU for about 100 ms per repeat burst and the browser's default timeout can be shorter. Lower the repeat count or accept the pause; the send still lands.
  • Volume changes only one step per tap. That is one repeat frame. sendNEC(code, 30) is the "hold the button down" pattern; the volume endpoints above already use it.
  • The AC reverts to old settings. You replayed a stale full-state code. Switch to the brand-specific AC sender class and set fields (e.g. setTemp, setFan) explicitly.

What to build next

  • The IR receiver tutorial is the capture half; run both sketches on one board for a learnable universal blaster.
  • Wire the endpoints into the MQTT publish-subscribe tutorial so any topic (e.g. home/ir/send) triggers a code instead of an HTTP call, which is how it joins a whole-home automation stack.
  • The ntfy notifications tutorial can add a button to a phone notification that fires the same endpoint (the "kill the AC from anywhere" pattern).
  • The ESP32 SD card datalogging tutorial pairs with this to record which commands fired and when.

The IoT with ESP32 book bundles receiver and transmitter into an IR hub project with this exact web UI pattern.


Chapter 94

ESP32: log sensor data to a microSD card

esp32 · 35 min

Every sensor project hits the same moment: the Serial Monitor is scrolling numbers, and you realize those numbers evaporate when you pull the plug. A microSD card fixes that for about $8. The ESP32 speaks SPI to a standard microSD module, the FAT filesystem on the card shows up on any laptop, and your plant moisture readings or garage temperature history become a CSV you can open in a spreadsheet. This tutorial covers the wiring, the timestamps, and the file rotation pattern that keeps one logger running for months instead of filling one giant unopenable file.

The trap: the sketch logs fine for hours, then the power blinks, and you lose not just the last line but the whole file, because the last kilobytes were still sitting in the write buffer. SD writes go through an internal buffer and only reach the card on a flush. Learn the flush rhythm before you deploy, not after the first outage (e.g. I lost two weeks of weather data to a five-second power dip before I made this automatic).

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (WROOM-32 devkit) 1 reads sensors, writes files over SPI $10
MicroSD card module with SPI interface 1 the card socket with level shifting $2
MicroSD card, 8-16 GB, Class 10 1 the storage; small is fine, FAT32 maxes at 32 GB $8
Jumper wires (6) 6 the SPI bus plus power $2

Any microSD module with the standard six pins (CS, SCK, MOSI, MISO, VCC, GND) works. Prefer the ones rated for 3.3 V logic; the cheap five-pin boards without level shifting usually still work on the ESP32 because its 3.3 V logic matches the card, but the level-shifted modules are more forgiving of wiring slop.

For the demo sketch, a potentiometer stands in for your real sensor (e.g. a BME280 or the analog mic from the sound sensor tutorial). Any analog input proves the logging pattern.

Nice to have

  • Soldering iron + solder: if your SD module came with unsoldered pin headers, which is common.
  • Iron stand, helping hands, soldering mat: the safety trio for that job.
  • Anti-static wristband: cards and modules survive carelessness, the ESP32 behind them does not.
  • Magnifying goggles: the silkscreen pin labels on these modules are tiny.
  • Wire stripper: for cut-to-length permanent wiring.
  • Multimeter: checking the module's VCC requirement (3.3 V vs 5 V input) before first power-up.

Wiring

The SPI pins here are the ESP32's default VSPI pins. Use them; every library example assumes them.

MicroSD module ESP32
CS GPIO 5
SCK GPIO 18
MOSI GPIO 23
MISO GPIO 19
VCC 5V (or 3.3V, per your module's regulator)
GND GND
Potentiometer wiper GPIO 34 (demo analog input)

Check your module before wiring power. Boards with an onboard regulator and level shifter take 5 V on VCC; bare-card sockets take 3.3 V and will not survive 5 V. The module's silkscreen or the listing page settles it in ten seconds.

Format the card as FAT32 before the first run. The ESP32's SD library reads FAT16/FAT32 only, and a card formatted exFAT by a modern OS will fail SD.begin() for reasons that look like dead hardware.

Install

Nothing to install. The SD.h and SPI.h libraries ship with the ESP32 Arduino core, and time.h is part of the core too. This is one of those rare builds where Arduino IDE >> Sketch >> Include Library >> Manage Libraries stays closed.

The code

The sketch logs an analog reading once per minute with a real timestamp from NTP, rotates to a new file every day, and flushes every line. The timestamp is the interesting half: the ESP32 has no battery-backed clock, so the sketch sets the system time from an NTP server over Wi-Fi at boot.

#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <time.h>

const int CS_PIN = 5;
const int POT_PIN = 34;          // demo analog input

const char* WIFI_SSID = "your-wifi-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* NTP_SERVER = "pool.ntp.org";
const long GMT_OFFSET_S = -7 * 3600;   // US Mountain Standard Time

File logFile;
String currentDate = "";

void connectTime() {
  configTime(GMT_OFFSET_S, 0, NTP_SERVER);
  struct tm timeinfo;
  while (!getLocalTime(&timeinfo, 10000)) {   // wait up to 10 s
    Serial.println("waiting for NTP...");
    delay(500);
  }
}

String nowStamp() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) return "1970-01-01 00:00:00";
  char buf[20];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
  return String(buf);
}

void openTodayFile() {
  struct tm timeinfo;
  getLocalTime(&timeinfo);
  char fname[16];
  strftime(fname, sizeof(fname), "/log_%F.csv", &timeinfo);
  bool fresh = !SD.exists(fname);
  logFile = SD.open(fname, FILE_WRITE);
  if (fresh && logFile) {
    logFile.println("timestamp,adc");   // header only for new files
    logFile.flush();
  }
  Serial.print("logging to ");
  Serial.println(fname);
}

void setup() {
  Serial.begin(115200);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(300); }
  connectTime();

  if (!SD.begin(CS_PIN)) {
    Serial.println("SD init failed (format card as FAT32?)");
    while (true) delay(1000);
  }
  openTodayFile();
}

void loop() {
  struct tm timeinfo;
  getLocalTime(&timeinfo);

  // Daily rotation: new file when the date changes
  char today[11];
  strftime(today, sizeof(today), "%F", &timeinfo);
  if (currentDate != String(today)) {
    currentDate = String(today);
    logFile.close();
    openTodayFile();
  }

  int adc = analogRead(POT_PIN);
  logFile.print(nowStamp());
  logFile.print(",");
  logFile.println(adc);
  logFile.flush();          // the line survives a power cut

  Serial.printf("logged %d\n", adc);
  delay(60000);             // one line per minute
}

Pull the card, put it in a laptop, and log_2026-09-23.csv opens in any spreadsheet with one row per minute. The file per day pattern keeps every file small enough to open in one window (about 1,440 rows at one reading per minute).

No Wi-Fi at the logging site

The NTP timestamp needs network at boot, or at least occasionally. If the logger runs disconnected, either log millis() offsets and reconcile later, or wire a DS3231 RTC module and read the time from it instead (the RTC has its own battery and survives power cuts; the ESP32 does not keep time across reboots without one).

FAT patterns worth knowing

  • Filenames, 8.3 style: the ESP32's SD library accepts long names on FAT32, but older tooling on the card may truncate log_2026-09-23.csv in odd ways. If a file refuses to open, shorten the name.
  • One file at a time: open, write, flush, and keep the handle only as long as you need it. Opening and closing a file every write is safe but slow (a few ms each way), fine at one line per minute, wasteful at 100 lines per second.
  • Card wear: cards wear by write cycles. One line per minute is nothing; 100 writes per second for months will kill a cheap card. Batch multiple readings into one line or one flush per interval when running fast.

What you learned

  • The ESP32 writes to standard FAT32 microSD cards over SPI with the built-in SD library: file open, print, flush, close.
  • The ESP32 has no battery-backed clock; NTP over Wi-Fi (or a DS3231 RTC) is what makes timestamps real.
  • flush() is the difference between a durable log and a buffer that vanishes with the power. Flush per line at low rates.
  • Daily rotation (one file per day) keeps files small and makes the log browsable by date.

When something breaks

  • "SD init failed" on every run. Format the card FAT32 (not exFAT), reseat it, and check the VCC level matches your module's regulator. Nine times out of ten it is the format.
  • Timestamps all show 1970. The sketch booted before NTP answered and your GMT offset is wrong on top of it. Watch for "waiting for NTP..." in the Serial Monitor and check the offset constant (the example is UTC-7 for US Mountain Standard Time).
  • SPI bus conflicts. If you add a second SPI device (an SD card plus a display), each needs its own CS pin and the bus gets touchy. Keep the card on the default VSPI pins in this tutorial and wire the second device to the HSPI pins instead.
  • File grows but shows garbage rows. The card is counterfeit or worn out. Counterfeit cards (a 16 GB that is really 2 GB with corrupted writes) are common on marketplaces; test with a write checker before trusting a logger to it.
  • Last readings lost after power cuts. You dropped the flush() or are writing so fast the flush cost dominates. Flush per line at one line per second or slower; batch and flush on an interval at high rates.

What to build next

  • The RCWL-0516 microwave radar tutorial pairs with this: each motion trigger becomes a timestamped row, a full motion log with no cloud.
  • The BME280 environment tutorial is the natural sensor for this logger: temperature, humidity, pressure, one row per minute.
  • The DS3231 RTC tutorial replaces the NTP dependency for loggers that live where Wi-Fi does not reach.
  • The ESP32 InfluxDB time series tutorial is the networked next step once a spreadsheet stops being enough (SD for the farm, Influx for the dashboard).

The IoT with ESP32 book bundles the SD logger with the sensor tutorials into a week-long data collection project chapter.


Chapter 95

ESP32: build a UI with LVGL on a touch display

esp32 · 60 min

An SSD1306 OLED gets you a 128x64 grid of dots and a display.print(). That is fine for a temperature. It is not fine for a thermostat. When you want labels, buttons, a bar chart, and touch input (e.g. a thermostat faceplate on the wall), you want LVGL: a full graphics library with widgets, styles, and animations that runs on an ESP32.

The trap I hit: I followed a video, everything compiled, and the screen stayed black. My buffer was fine, my pins were fine. The problem was that LVGL is not a fire-and-forget render loop. It needs its lv_timer_handler() called every few milliseconds forever, and the tutorial's delay(1000) in loop() was starving it. Structure the sketch as two FreeRTOS tasks from the start (one draws, one reads touch and runs your logic) and LVGL just works.

What you need

Needed

  • ESP32 dev board, a plain WROOM-32 (about $8). No PSRAM needed for 320x240 at 16-bit color with one buffer.
  • 2.4 inch ILI9341 resistive touch display module, 320x240 SPI, with the XPT2046 touch controller on the back (about $10). Get the module with the touch chip included, the display-only ones will waste an afternoon.
  • Jumper wires (a lot of them, this module uses every pin)
  • Breadboard, or better, female-to-female jumpers straight to the module header

Nice to have

  • Helping hands or a third hand tool for holding the module while you probe pins
  • Magnifying goggles for reading the silkscreen labels (they are tiny and inconsistent between sellers)
  • Multimeter for verifying 3.3 V at the module before first power-up
  • Soldering iron + solder if your module ships with an unsoldered header strip
  • Soldering mat and iron stand
  • Anti-static wristband

Wiring

This is SPI for the display plus a second SPI for the touch controller (they share MISO/MOSI/SCK, separate chip selects).

Display pin Connects to
VCC ESP32 3.3V
GND ESP32 GND
CS ESP32 GPIO 5
RESET ESP32 GPIO 4
DC ESP32 GPIO 2
SDI (MOSI) ESP32 GPIO 23
SCK ESP32 GPIO 18
LED ESP32 3.3V
SDO (MISO) ESP32 GPIO 19
Touch pin Connects to
T_CLK ESP32 GPIO 18 (shared)
T_CS ESP32 GPIO 15
T_DI (MOSI) ESP32 GPIO 23 (shared)
T_DO (MISO) ESP32 GPIO 19 (shared)
T_IRQ ESP32 GPIO 25
T_CTRL leave unconnected on most modules

This module is 3.3 V only. There is no 5 V tolerance on the ILI9341 or the XPT2046. Do not wire it to 5 V "to make it brighter", that is what the LED pin is for.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries:

  • Search "lvgl", install LVGL by kisvp (version 9.x). The code below is written for 9.
  • Search "XPT2046", install XPT2046_Touchscreen by Paul Stoffregen.

Then Arduino IDE >> Tools >> manage the board settings: Flash Size 4MB (or larger), Partition scheme "Huge APP". LVGL 9 with one widget screen will not fit in the default 1.2 MB app partition. This is the other black-screen cause nobody warns you about (the upload fails or the board boot-loops).

The code

#include <lvgl.h>
#include <TFT_eSPI.h>       // install Bodmer's TFT_eSPI, configure User_Setup.h per its docs
#include <XPT2046_Touchscreen.h>
#include <SPI.h>

// --- Display buffer ---
#define SCREEN_W 320
#define SCREEN_H 240
static lv_color_t buf1[SCREEN_W * 100];   // 100 lines, ~64KB, fits in RAM

lv_display_t* disp;

// --- Pins (match the wiring table) ---
#define TFT_CS    5
#define TFT_RST   4
#define TFT_DC    2
#define TOUCH_CS  15
#define TOUCH_IRQ 25

XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);

// --- LVGL display flush: push pixels to the ILI9341 ---
void my_flush(lv_display_t* d, const lv_area_t* area, uint8_t* px_map) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);
  TFT_eSPI& tft = *(TFT_eSPI*)lv_display_get_user_data(d);
  tft.startWrite();
  tft.setAddrWindow(area->x1, area->y1, w, h);
  tft.pushPixels(px_map, w * h);
  tft.endWrite();
  lv_display_flush_ready(d);
}

// --- LVGL touch read ---
void my_touch_read(lv_indev_drv_t* drv, lv_indev_data_t* data) {
  if (touch.tirqTouched() && touch.touched()) {
    TS_Point p = touch.getPoint();
    // XPT2046 raw range ~200..3900, map to screen
    data->point.x = map(p.x, 200, 3900, 0, SCREEN_W - 1);
    data->point.y = map(p.y, 200, 3900, 0, SCREEN_H - 1);
    data->state = LV_INDEV_STATE_PRESSED;
  } else {
    data->state = LV_INDEV_STATE_RELEASED;
  }
}

// --- UI objects (globals so the task can update them) ---
lv_obj_t* temp_label;
lv_obj_t* bar;

// --- App logic task: update widgets every second ---
void logic_task(void* arg) {
  for (;;) {
    float fake_temp = 20.0 + (random(0, 60) / 10.0);
    lv_label_set_text_fmt(temp_label, "%.1f C", fake_temp);
    lv_bar_set_value(bar, (int32_t)(fake_temp * 2), LV_ANIM_ON);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(TFT_RST, OUTPUT);
  digitalWrite(TFT_RST, HIGH);
  delay(50);
  digitalWrite(TFT_RST, LOW);
  delay(50);
  digitalWrite(TFT_RST, HIGH);

  static TFT_eSPI tft;
  tft.begin();
  tft.setRotation(1);           // landscape
  tft.fillScreen(TFT_BLACK);

  touch.begin();
  touch.setRotation(1);         // match the display rotation

  lv_init();
  disp = lv_display_create(SCREEN_W, SCREEN_H);
  lv_display_set_user_data(disp, &tft);
  lv_display_set_flush_cb(disp, my_flush);
  lv_display_set_buffers(disp, buf1, NULL, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);

  lv_indev_t* indev = lv_indev_create();
  lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
  lv_indev_set_read_cb(indev, my_touch_read);
  lv_indev_set_display(indev, disp);

  // A tiny UI: title, big temperature, bar
  lv_obj_t* scr = lv_screen_active();
  lv_obj_set_style_bg_color(scr, lv_color_hex(0x101418), 0);

  lv_obj_t* title = lv_label_create(scr);
  lv_label_set_text(title, "Workshop bench");
  lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8);

  temp_label = lv_label_create(scr);
  lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, 0);
  lv_label_set_text(temp_label, "-- C");
  lv_obj_align(temp_label, LV_ALIGN_CENTER, 0, -10);

  bar = lv_bar_create(scr);
  lv_obj_set_size(bar, 260, 14);
  lv_obj_align(bar, LV_ALIGN_CENTER, 0, 40);
  lv_bar_set_range(bar, 0, 100);

  // Two-task structure. This is the part that makes LVGL stable:
  xTaskCreatePinnedToCore(lvgl_task, "lvgl", 8192, NULL, 2, NULL, 1);  // core 1
  xTaskCreatePinnedToCore(logic_task, "logic", 4096, NULL, 1, NULL, 0); // core 0
}

// --- LVGL task: never touch UI objects from anywhere else ---
void lvgl_task(void* arg) {
  for (;;) {
    lv_timer_handler();       // runs render + input
    vTaskDelay(pdMS_TO_TICKS(5));
  }
}

void loop() {
  // empty on purpose: everything lives in the two tasks
}

// UI updates must go through the LVGL task. If logic_task ever needs
// to change a widget from another core, use lv_async_call() instead
// of touching the object directly (it schedules the change inside the
// LVGL task, which is the only safe place).

The code above uses TFT_eSPI for the ILI9341 transport. TFT_eSPI is configured by editing User_Setup.h in the library (its comment block lists every ILI9341 board preset), not from your sketch. That is the one library in this stack you configure by editing a file, and it is why the pins above are repeated there.

What you learned

  • LVGL is a retained-mode widget library, not a pixel-pushing one. You build the UI once, LVGL redraws only what changed.
  • LVGL needs lv_timer_handler() every few ms. A delay() in loop() freezes the UI, so the working structure is two FreeRTOS tasks on two cores.
  • Touch input is an input device you register, not a loop you poll yourself. LVGL calls your read function.
  • Only the LVGL task may touch UI objects. From another task, use lv_async_call().

When something breaks

  • Black screen, code compiles fine. Two usual causes: the app partition is too small (Arduino IDE >> Tools >> Partition scheme

    Huge APP), or lv_timer_handler() is starved by a delay() in loop(). Fix the partition first, then check the task setup.

  • White screen with noise. SPI pins in User_Setup.h do not match the wiring table. The module silkscreen labels vary between sellers (e.g. some print SDA for MOSI), so trust your wiring, not the label.
  • Display works, touch is dead. TOUCH_CS is floating. The touch controller shares the SPI bus, and without its chip select it never answers. GPIO 15 in the table above is required, not optional.
  • Touch works but is mirrored or offset. The XPT2046 raw values need calibration. Change the map() endpoints in my_touch_read until the stylus lands where it points, and swap x/y if the rotation is wrong (set both display and touch rotations to the same value first).
  • Random crashes after minutes. You called a UI function from logic_task. Move it behind lv_async_call().

What to build next

Put a real sensor behind the fake temperature: the BME280 tutorial's reading feeds lv_label_set_text_fmt() exactly as written above, and the MQTT tutorial turns the same UI into a remote control (subscribe, then update the label from the callback through lv_async_call()).

For a wall-mounted build, this display plus the SSD1306 tutorial's lessons about brightness is how you get to a thermostat faceplate. And when the UI needs live data from a browser at the same time, the WebSocket server tutorial feeds both from one chip.


Chapter 96

ESP32: read battery voltage and fuel gauge (MAX17048) instead of guessing

esp32 · 30 min

Every battery-powered ESP32 project I built before this one guessed. I read the battery voltage with the ADC, divided by a calibration constant, and mapped it to a percentage with a lookup table I found in a forum. It failed in exactly the way everyone's fails: 100% for a long time, then a cliff, then "20%" that was really zero. A lithium discharge curve is flat. Voltage is a terrible proxy for state of charge, and the lookup table is where that lie gets published.

The MAX17048 is a fuel gauge chip that fixes this properly. It runs a real state-of-charge model (the ModelGauge algorithm, which combines voltage and a coulomb counter) and answers "what percent is left" over I2C in two bytes. It costs about $2 on a breakout.

The trap I hit: I read the percentage once at boot, after the chip had been sitting on the shelf with no battery connected, and my project reported 0% forever. The MAX17048 needs a first real battery connection to learn its baseline. If the battery arrives after the chip has been sitting unpowered, give it a couple of minutes to settle before you trust the number.

What you need

Needed

  • ESP32 dev board (e.g. a Feather-style board that already breaks out I2C, about $8)
  • MAX17048 breakout module (about $2; the Adafruit one is labeled MAX17048 and has the battery JST header on board)
  • A single-cell LiPo battery, 3.7 V, with a JST-PH connector or the leads to crimp one on
  • Jumper wires, female-to-female

Nice to have

  • Soldering iron + solder, only if your battery leads need a JST connector crimped or soldered on
  • Wire stripper for prepping those leads
  • Soldering mat and iron stand
  • Multimeter to cross-check the voltage reading (the gauge and your meter should agree within about 20 mV)
  • Anti-static wristband for handling the breakout
  • A USB power meter inline so you can watch charge current for real (e.g. a $10 inline power profiler)

Wiring

I2C on the ESP32: SDA is GPIO 21, SCL is GPIO 22 by default.

MAX17048 Connects to
VIN ESP32 3.3V
GND ESP32 GND
SDA ESP32 GPIO 21
SCL ESP32 GPIO 22
BAT (cell +) Battery positive wire
GND (cell -) Battery negative wire

The battery powers your project through its own path (e.g. the TP4056 tutorial's charge board or a Feather's built-in regulator). The MAX17048 does not pass battery power to the ESP32. It listens to the cell and reports. Keep the load path and the sensing path separate in your head and the wiring stays simple.

Do not set a raw LiPo down with both leads touching metal. A dead shorted LiPo is a fire. Tape the leads until the moment you screw them down.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "MAX1704X", install the Adafruit MAX1704X library (it covers the MAX17048 and its siblings).

The code

#include <Wire.h>
#include <Adafruit_MAX1704X.h>

Adafruit_MAX17048 gauge;

unsigned long lastPrint = 0;

void setup() {
  Serial.begin(115200);

  if (!gauge.begin()) {
    Serial.println("MAX17048 not found. Check wiring, I2C addr 0x36.");
    while (true) delay(1000);
  }
  Serial.print("Chip version: ");
  Serial.println(gauge.getVersion(), HEX);

  // A chip that just met its battery needs a moment to settle.
  delay(2000);
}

void loop() {
  if (millis() - lastPrint >= 2000) {
    lastPrint = millis();

    float cellV      = gauge.cellVoltage();   // volts
    float cellPct    = gauge.cellPercent();   // 0-100
    float chargeRate = gauge.chargeRate();    // %/hour, negative = discharging

    Serial.printf("V: %.3f  SOC: %.1f%%  rate: %+.2f %%/h\n",
                  cellV, cellPct, chargeRate);
  }
}

Open the serial monitor and let the battery carry the load alone for a few minutes. You should see the percentage sit still while the voltage drops a little, then both move together as the cell empties. That flat voltage with steady percent is the whole reason the chip exists.

What each number is for:

  • cellPercent is the headline. This is what you show the user and what you use to decide when to sleep or shut down.
  • cellVoltage is the sanity check. If the percentage and the voltage disagree wildly (e.g. 80% at 3.5 V, which is not plausible), the model needs a reset (see troubleshooting).
  • chargeRate tells you charging from discharging without any wiring to the charge controller. A solar node knows whether it is winning or losing the day.

What you learned

  • Voltage is a bad proxy for state of charge on lithium cells. The discharge curve is flat exactly where you live.
  • The MAX17048 runs a real gauge model and reports percent, voltage, and rate over I2C at address 0x36.
  • The sensing path and the power path are separate. The gauge listens; it does not feed the ESP32.
  • The chip needs one real battery connection to learn its baseline. Numbers right after first contact are not to be trusted.

When something breaks

  • 0% forever. The chip reset with the battery disconnected and never learned the cell. Reconnect the battery while powered, wait a few minutes, and re-read. If it persists, call gauge.restart() (a soft reset of the model, not a wipe of your code).
  • Percentage jumps around. You are reading it while the load pulses (e.g. a Wi-Fi burst every few seconds drags the cell down for 200 ms). Read less often, or average a few reads, or trust the gauge's own filtering over minutes.
  • I2C scan finds nothing at 0x36. SDA and SCL are swapped, or the breakout VIN is fed 5 V into a 3.3 V-only variant. Check the wiring table above before suspecting the chip.
  • Numbers disagree with your multimeter. Measure at the BAT pin, not the JST header on the far side of a switch or protection circuit. A protection IC between the cell and the gauge drops tens of millivolts under load.

What to build next

This is the missing piece for the 18650 + TP4056 tutorial: add the gauge and that power board becomes a battery node that knows when to stop trying. Combine it with the deep sleep tutorial and the node sleeps at 10%, wakes, publishes one MQTT message, and goes back to sleep (the fuel gauge keeps its model across deep sleep because the state lives in the chip, powered by the cell itself).

If you are powering from solar, pair it with the solar battery tutorial: chargeRate() is how you log whether the panel is keeping up, and the ntfy notifications tutorial is how you get told when it stops.


Chapter 97

ESP32: read sound level with an analog microphone module

esp32 · 25 min

The INMP441 I2S microphone tutorial is the good way to capture audio on an ESP32: digital, clean, one spare wire of noise. This tutorial is the cheap way, and sometimes cheap is the right tool. An analog mic module is one wire to one ADC pin, no I2S setup, and it answers the question most projects actually have: "how loud is it right now?" (e.g. clap-triggered lights, a fan that runs only while the shop tools run, a noise log for the room next to the nursery). If you only need a level and a threshold, a $2 analog module gets you there in twenty minutes.

The trap: people wire the module, print analogRead(), and get a number that hovers around 2048 forever. That is the ADC idling at the middle of the waveform, which is exactly what an audio signal is: an oscillation around a midpoint, not a rising DC level. The fix is computing the peak or RMS of a whole buffer of samples instead of reading one sample per loop. The reading-per-loop mistake is the number one reason analog mic tutorials "don't work."

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (WROOM-32 devkit) 1 reads the ADC, runs the level math $10
MAX4466 adjustable-gain mic module 1 electret capsule plus amplifier, gain pot on board $3
Jumper wires (3) 3 VCC, GND, OUT to ADC $1
Breadboard 1 holding the module while you test $3

Why the MAX4466 over the even cheaper LM393-style sound sensor boards: the LM393 boards output a comparator signal (a square wave that flickers with sound), which answers "is there sound" but not "how loud". The MAX4466 outputs the actual amplified waveform on its analog pin, so you can compute real levels, and it has a gain trimpot to match your room. (The LM393 boards also have an analog tap on some clones, but the MAX4466 is the one designed for it.)

Nice to have

  • Multimeter: verify the module's VCC and that its OUT idles near the midpoint voltage before the code looks at it.
  • Soldering iron + solder: the MAX4466 usually ships with the header unsoldered.
  • Helping hands, iron stand, soldering mat: the soldering support trio.
  • Anti-static wristband: the electret capsule is happier not zapped.
  • Magnifying goggles: the gain pot and pin labels are small.
  • Wire stripper: for the permanent install.

Wiring

One signal wire. The analog pin is the whole interface.

MAX4466 ESP32
Vcc 3.3V
GND GND
OUT GPIO 34 (ADC1_CH6, input only)

Use an ADC1 pin (GPIOs 32-39). The ADC2 pins (GPIOs 0, 2, 4, 12-15, 25-27) stop working whenever Wi-Fi is on, which is a surprise you do not want after you have already mounted the thing. GPIO 34-39 are input-only, which is fine because this module only outputs.

Keep the module a few centimeters off the breadboard rail that shares power with anything noisy. The mic hears its own power rail (e.g. a shared rail with an LED makes the LED's ripple show up as a sound floor).

Install

Nothing to install. The ADC, the level math, and the Wi-Fi are all in the ESP32 core, so Arduino IDE >> Sketch >> Include Library >> Manage Libraries stays shut for this one.

The code

The sketch samples a window of audio, computes both peak and RMS, and thresholds the RMS for a clap trigger. RMS (square every sample, average, square root) is the honest "energy in this window" number; peak answers "did anything spike" and is what a click does its worst to.

const int MIC_PIN = 34;        // ADC1, input-only pin
const int SAMPLES = 2000;      // ~30 ms of audio at default ADC speed

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);    // 0-4095
  Serial.println("analog mic level meter ready");
}

void loop() {
  uint64_t sumSquares = 0;
  int peak = 0;
  int midpoint = 2048;         // refine below from actual data

  // First pass: find the midpoint (DC bias) of this window
  int sum = 0;
  for (int i = 0; i < SAMPLES; i++) {
    sum += analogRead(MIC_PIN);
  }
  midpoint = sum / SAMPLES;

  // Second pass: peak and RMS around the midpoint
  for (int i = 0; i < SAMPLES; i++) {
    int v = analogRead(MIC_PIN) - midpoint;
    if (v < 0) v = -v;
    if (v > peak) peak = v;
    sumSquares += (int64_t)v * v;
  }
  float rms = sqrt((float)(sumSquares / SAMPLES));

  Serial.printf("peak: %4d  rms: %6.1f\n", peak, rms);

  if (rms > 300) {             // tune this to your room
    Serial.println("LOUD: clap detected");
    delay(1000);               // crude debounce
  }
  delay(50);
}

Upload it and open the Serial Monitor at 115200. A quiet room shows an RMS in the tens; talking near the module pushes it to hundreds; a clap spikes it past a thousand. The gain pot on the MAX4466 sets how much of the ADC range your room's sounds use: turn it up for a whole-room meter, down for a clap trigger on a desk.

Two numbers worth knowing: two full-scale ADC references per loop means about 25,000 reads per second at default settings, so the 30 ms window is honest audio sampling. And int64_t for sumSquares is not optional paranoia: squaring 12-bit values overflows a 32-bit int after a few hundred samples.

The threshold, honestly

Every room has a noise floor, and every mic module has a gain setting, so no tutorial can hand you the number. Watch the RMS output for a minute, note the quiet-room value, note the clap value, and pick a threshold between them with margin on the quiet side. Then log it for a day (the SD card datalogging tutorial is the natural partner) and adjust once. That is the whole tuning process, and it is the same for every sound sensor in every project.

What you learned

  • An analog mic module is one wire to an ADC1 pin, and the whole interface is analogRead().
  • One analogRead() per loop is the classic mistake: audio is an oscillation around a midpoint, so you must window it and compute peak or RMS.
  • Peak for clicks, RMS for sustained sound; threshold the RMS with margin over your room's measured floor.
  • I2S (the INMP441 tutorial) is the upgrade path when you want actual audio, not just a level.

When something breaks

  • The reading barely moves. You are reading once per loop (see the trap), or the gain pot is fully down, or the module's OUT is not actually on your ADC1 pin. Window it first, then check gain.
  • The level is maxed at 4096 constantly. Gain too high or the module's amp is clipping: turn the trimpot down until a normal voice sits mid-range.
  • Wi-Fi on, readings go flat. You are on an ADC2 pin. Move to GPIO 32-39; ADC2 loses to Wi-Fi by design, not by defect.
  • Random triggers at night. The threshold sits too close to the noise floor, and the floor rises (an HVAC cycling on, a fridge). Raise the margin, or use a longer window so one blip cannot cross it.
  • The module reads but sounds muffled or one-sided. The capsule hole is covered or facing the wall. These modules hear best through the little hole in the top of the capsule; do not bury it in hot glue.

What to build next

  • The INMP441 I2S microphone tutorial is the digital upgrade: same room, real 16-bit audio, wake words and WAV captures instead of a level.
  • The ESP32 SD card datalogging tutorial turns this meter into a noise logger with timestamps (the "how loud is the workshop really" project).
  • The ntfy notifications tutorial turns the clap threshold into a phone alert (the back-door monitor: two claps means someone is in the garage).
  • The MQTT publish-subscribe tutorial feeds the RMS level into a home automation stack as shop/noise/level.

The IoT with ESP32 book bundles both microphone tutorials with the logging and notification tutorials into a sound chapter.


Chapter 98

ESP32: add 8 analog inputs over SPI with the MCP3008

esp32 · 35 min

The ESP32 datasheet advertises 18 analog inputs. Reality: ADC2 shares its pins with Wi-Fi, so the moment WiFi.begin() runs, roughly half the analog pins go dead, and the pins that survive sit partly in the nonlinear bottom of the ADC's range. I hit this mid-project with a three-potentiometer control panel: every channel read fine until the Wi-Fi stack started, then two of the three went flat. The ADC basics tutorial on this site explains that trap in depth. This tutorial is the other half of the fix: stop fighting the built-in ADC and hang 8 clean inputs off the SPI bus with an MCP3008 for about $4.

The trap is power, and it bites in one specific direction. Most MCP3008 wiring diagrams you will find were drawn for the Arduino Uno, a 5V board, so they show VDD and VREF both going to 5V. Do that on an ESP32 and the chip keeps working, which is the insidious part. What you get is a chip that drives its DOUT pin at 5V into a 3.3V-only input, and a full-scale range of 5V that throws away resolution your 3.3V signals never needed. Keep VDD and VREF at 3.3V. Then logic levels, full-scale range, and the ESP32 all agree, and you never have to think about it again.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (ESP32-DevKitC or clone) 1 the brain $8-$15
MCP3008 ADC (DIP chip or breakout) 1 8-channel 10-bit ADC over SPI $4-$6
10K potentiometer 2 something analog to read while testing $2
Breadboard 1 connecting it up $3
Jumper wires 10 SPI bus + channel connections $2

Any 0-3.3V analog signal works on the channels: potentiometers, an LDR divider, a soil moisture probe, the output of a sensor board. The two pots are just the cheapest reliable test signal I know.

Nice to have

  • Multimeter to confirm the 3.3V rail is actually 3.3V before you blame the chip
  • Soldering iron and solder if your breakout came with an unsoldered header
  • Soldering iron stand, helping hands, and a soldering mat to keep the header job uneventful
  • Anti-static wristband for handling the bare DIP chip
  • Magnifying goggles for reading the DIP pin markings without guessing
  • Wire stripper for clean leads on the potentiometers

Wiring

MCP3008 pin Connect to
VDD 3.3V
VREF 3.3V
AGND GND
DGND GND
CLK GPIO 18
DOUT (MISO) GPIO 19
DIN (MOSI) GPIO 23
CS/SHDN GPIO 5
CH0 your first analog signal
CH1 to CH7 more signals, or GND through 10K if unused

Tie every unused channel to GND through a 10K resistor. A floating input can bleed charge through the internal multiplexer and show up as ghost readings on the channel you are actually using.

GPIO 18, 19, and 23 are the ESP32's default hardware SPI pins and GPIO 5 is the default chip select, so the sketch below needs no pin remapping. Avoid GPIO 0, 2, 12, and 15 for CS: those are boot-strapping pins and a pull-up or signal on them at reset can stop the board booting (the boot pins tutorial covers which ones to avoid).

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search "MCP3008" >> install Adafruit MCP3008. That is the only dependency; the SPI peripheral ships with the core.

Why the MCP3008 and not the ADS1115

The site has an ADS1115 tutorial, and the two chips answer different questions. The ADS1115 gives you 16 bits over I2C but only 4 channels and a top speed around 860 samples per second. The MCP3008 gives you 10 bits over SPI, 8 channels, and roughly 75 thousand samples per second at 3.3V. Rule of thumb: precision (a load cell, a slow bridge signal) goes to the ADS1115, quantity and speed (eight pots, a bank of LDRs, a fast changing voltage) goes to the MCP3008. If you need both, they share the bus happily since one speaks I2C and the other SPI.

The code

#include <SPI.h>
#include <Adafruit_MCP3008.h>

Adafruit_MCP3008 adc;

const int CS_PIN  = 5;
const float VREF_MV = 3300.0;   // must match what the 3.3V rail measures

void setup() {
  Serial.begin(115200);
  if (!adc.begin(CS_PIN)) {
    Serial.println("MCP3008 begin() failed, check wiring");
    while (1) delay(1000);
  }
}

void loop() {
  for (int ch = 0; ch < 8; ch++) {
    uint32_t sum = 0;
    for (int i = 0; i < 16; i++) {
      sum += adc.readADC(ch);
    }
    float raw = sum / 16.0;
    float mv  = raw * (VREF_MV / 1023.0);

    Serial.printf("CH%d  raw %5.1f  %6.1f mV\n", ch, raw, mv);
  }
  Serial.println("---");
  delay(1000);
}

The 16-sample average is not decoration. The MCP3008 is a fast, successive-approximation converter with no internal averaging, so the last digit flickers on any real signal. Sixteen samples take well under a millisecond and settle it. If you need faster scanning, drop the average to 4 or 1; the chip itself can run far quicker than this loop asks it to.

Note the VREF_MV constant: measure your board's 3.3V rail once with a multimeter and put the real number in. Rails run 3.25 to 3.35V, which is a 1.5% error if you assume 3300 exactly (e.g. a rail that measures 3.28 V turns 1000 mV into a reading of 1009 mV).

What you learned

  • SPI buys you 8 analog inputs for 4 GPIO pins, and none of them care whether Wi-Fi is running, because they are not on the ESP32's ADC at all.
  • Powering the ADC at 3.3V makes its logic levels and full-scale range match the ESP32 exactly. The 5V in Arduino diagrams is someone else's problem, and copying it here costs you a damaged input.
  • Sixteen-sample averaging removes converter flicker for free, and one measured constant (the actual rail voltage) removes the biggest systematic error.

When something breaks

  • Every channel reads 0: CS is not actually toggling. Check the CS wire goes to GPIO 5 and not the adjacent pin, and that the breadboard row under the chip is making contact.
  • Readings jump between two values that never change: you are reading a floating channel. Tie unused channels to GND through 10K and see if the ghosts disappear.
  • The chip got hot: VDD and VREF got swapped with AGND in a breadboard shuffle, or 5V reached the board. Power off, check with the multimeter, replace the chip if it took the hit (they are $4, that is why we buy two).
  • Values look fine until Wi-Fi connects, then CH0 goes weird: the sensor is on an ADC2 pin of the ESP32 as well as the MCP3008, or the SPI wires run next to the antenna. Keep the SPI bus short and away from the antenna end of the board.
  • Top of the pot range never reaches 1023: the signal's top is below VREF, which is correct behavior. If you need the full 1023 counts for a smaller signal, that is a job for the ADS1115's gain (the differential tutorial on this site does exactly that).

What to build next

  • The ESP32 NTC thermistor tutorial on this site reads temperature with a resistor divider; that divider plugs straight into CH0 and now it can sit far from the board.
  • The ESP32 ADS1115 external ADC tutorial is the I2C, 16-bit route when you need resolution more than channel count.
  • Pair the eight channels with MQTT publishing (the ESP32 MQTT tutorial covers it) and every pot and probe lands on a dashboard as its own topic.
  • The ADC basics tutorial explains the built-in ADC's quirks, which is the problem this chip solves.

The book IoT with ESP32 bundles the sensor tutorials including this one.


Chapter 99

ESP32: read temperature with a $1 NTC thermistor (Steinhart-Hart done simply)

esp32 · 30 min

An NTC thermistor is a resistor that gets less resistive as it gets warmer. That is the whole sensor. Pair it with a fixed 10K resistor, read the voltage in the middle, and you have a temperature sensor for a dollar that survives water, dust, and being stepped on (things that retire a BME280 in one afternoon). I use them for anything that does not need laboratory accuracy: the garage, the compost pile, the 3D printer enclosure, the fridge.

The trap: my first version read the raw ADC count, mapped 0 to 4095 onto 0 to 3.3V linearly, and called it done. The numbers moved in the right direction, so it looked finished, and the garage sensor sat persistently 4 to 5 degrees off for a month before I checked it against a thermometer. The ESP32's ADC is just not linear across its range, especially at the ends. The fix is one function: analogReadMilliVolts() applies the factory calibration stored in the chip and returns actual millivolts. Same hardware, one function change, and the error dropped to under a degree.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (ESP32-DevKitC or clone) 1 the brain $8-$15
10K NTC thermistor, B = 3950 (e.g. MF52 or MF58 bead type) 1 the temperature sensor $1
10K resistor, 1% 1 the fixed half of the divider $0.10
Breadboard 1 connecting it up $3
Jumper wires 3 divider to ESP32 $1

Two details worth the extra cents. Get the 1% resistor, not 5%: the fixed resistor's value goes into the math directly, so its error is your error, permanently. And check the thermistor's datasheet for the B value (3950 is the common one, 3435 shows up too); a wrong B value is the number one cause of "works but reads odd".

Nice to have

  • Multimeter to sanity-check the divider voltage against the printed millivolts
  • Soldering iron and solder if you are attaching longer leads to the thermistor for a remote location
  • Soldering iron stand and helping hands for that lead job
  • Anti-static wristband when handling the bare board
  • Magnifying goggles for reading resistor bands (1% brown-black- orange vs the 5% gold band misread)
  • Soldering mat to keep the bench clean
  • Wire stripper for the thermistor leads

Wiring

NTC circuit node ESP32 pin
Thermistor leg 1 3.3V
Thermistor leg 2 + one end of the 10K GPIO 34
Other end of the 10K GND

This orientation (thermistor on the 3.3V side, fixed resistor on the GND side) means warmer temperatures push the divider voltage up. Swap the two parts and the readings invert direction; the code below has a one-line change for that case, but pick one and be consistent.

Use an ADC1 pin: GPIO 32 to GPIO 39. GPIO 34 to 39 are input-only, which is fine here, but note they have no internal pull-ups, so the external 10K does real work. ADC2 pins stop working once Wi-Fi is on, which the ADC basics tutorial covers in full.

The math, done simply

The honest equation for an NTC is the Steinhart-Hart equation, a cubic in 1/T with three coefficients you fit from a datasheet table. The simplified version nearly every hobby project actually uses is the beta equation:

1/T = 1/T0 + (1/B) * ln(R/R0)

T0 is 298.15 K (25 degrees C), R0 is the resistance at 25 C (10K here), and B is the material constant from the datasheet (3950 here). For a typical 10K NTC between 0 and 70 C, the beta version stays within about half a degree of the full equation, which is better than the rest of your parts anyway. If you later need real accuracy, measure the thermistor at three temperatures (e.g. ice water, room, boiling) and fit the three Steinhart-Hart coefficients yourself. Start with beta; most projects never need the cubic.

The code

#include <math.h>

const int   NTC_PIN    = 34;
const float SERIES_R   = 10000.0;  // the fixed resistor, measured if you can
const float R0         = 10000.0;  // thermistor resistance at 25 C
const float B          = 3950.0;   // from the thermistor's datasheet
const float T0         = 298.15;   // 25 C in kelvin
const float VCC_MV     = 3300.0;   // measure your 3.3V rail once

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);
  analogSetAttenuation(ADC_11db);   // full 0-3.3V window
}

void loop() {
  uint32_t sum_mv = 0;
  const int n = 32;
  for (int i = 0; i < n; i++) {
    sum_mv += analogReadMilliVolts(NTC_PIN);
    delay(2);
  }
  float mv = sum_mv / (float)n;

  // Thermistor on the 3.3V side, fixed resistor on the GND side:
  //   ratio = SERIES_R / (SERIES_R + R_ntc)  so  R_ntc = SERIES_R * (1-ratio)/ratio
  float ratio = mv / VCC_MV;
  float r_ntc = SERIES_R * (1.0 - ratio) / ratio;

  // Beta equation: 1/T = 1/T0 + (1/B) ln(R/R0)
  float temp_c = 1.0 / (1.0 / T0 + log(r_ntc / R0) / B) - 273.15;

  Serial.printf("millivolts %.1f  R_ntc %.0f  temp %.2f C\n",
                mv, r_ntc, temp_c);
  delay(1000);
}

If you wired the thermistor to GND and the fixed resistor to 3.3V, change the resistance line to r_ntc = SERIES_R * ratio / (1.0 - ratio). Everything downstream stays the same.

The 32-sample average costs about 64 ms and is what makes the printed value sit still. A single ADC read on a divider this high-impedance wobbles in the last digit.

Calibrate it once, cheaply

You do not need a reference thermometer to fix a small constant offset. Two glasses of water and a thermometer you trust: ice water should read 0 C, and tap-hot water compared against a cooking thermometer tells you the rest. If the sensor reads a consistent 3 degrees high, subtract 3 in code and be done. A constant offset is the failure mode of a wrong or imprecise resistor; a reading that is only right in the middle of the range is the failure mode of raw ADC counts, which you already fixed with analogReadMilliVolts().

What you learned

  • An NTC plus one fixed resistor is a complete temperature sensor, and the beta equation converts resistance to temperature in one line.
  • analogReadMilliVolts() reads the chip's factory calibration and skips the raw-counts-to-volts guesswork that cost me a month of accuracy.
  • Averaging many short reads settles a high-impedance divider, and one measured constant (the real 3.3V rail) beats a labeled one.

When something breaks

  • Reads a constant nonsense temperature around -273 or a math error: the ratio is out of range, which means the pin reads near 0 or near 3.3V. A leg came off, or you are on a pin with no divider connected.
  • Temperature moves the wrong way when you warm it: the divider is oriented opposite to the code. Use the alternate resistance line above (or swap the parts on the breadboard).
  • Off by a fixed few degrees: wrong B value, or a 5% fixed resistor, or the 3.3V rail is not really 3.3V. Measure the rail, check the datasheet, and if a constant offset remains, calibrate it out as above.
  • Readings go wild once Wi-Fi starts: you are on an ADC2 pin. Move the divider to GPIO 32 to 39 (ADC1) and it survives Wi-Fi.
  • Cable longer than a meter, readings drift: voltage drop and pickup on long analog runs. Put an MCP3008 (the SPI ADC tutorial on this site) near the sensor and send SPI instead of analog.

What to build next

  • The ESP32 DS18B20 tutorial is the calibration-free alternative: a digital temperature sensor with the math already done inside.
  • The BME280 tutorial adds humidity and pressure if the project is weather-shaped rather than temperature-shaped.
  • Hang the thermistor off an MCP3008 channel (the SPI ADC tutorial on this site) when the sensor lives more than a meter from the board.
  • Log the readings with the InfluxDB timeseries tutorial and the garage finally gets a temperature graph.

The book IoT with ESP32 bundles the sensor tutorials including this one.


Chapter 100

ESP32: sync time over NTP and handle timezones + DST correctly

esp32 · 30 min

Half the ESP32 projects that log anything eventually need to know what time it actually is. Not "milliseconds since boot", which is what millis() gives you, but wall-clock time that survives a reboot, agrees with the rest of the house, and knows that March 8th is not the same as March 8th in November. NTP (the Network Time Protocol, a protocol older than most of its users) gets you that in about ten lines, and the ESP32's SDK has the timezone and daylight-saving machinery already built in. The part nobody shows you is the one line of configuration that makes DST work, so most tutorials skip it and your timestamps go wrong twice a year.

The trap is the epoch. time(nullptr) after boot returns something like 315,583,200 seconds (1979, in my case, every single time) because the ESP32 has no battery-backed clock: the year is whatever the SDK's default is until the first NTP sync completes. Sketches that print the time immediately in setup() print 1980-something, conclude "NTP broken", and start hardcoding offsets. The fix is not code, it is patience plus one check: wait for the sync to actually finish before you trust the clock.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (ESP32-DevKitC or clone) 1 the brain $8-$15
Wi-Fi network with internet access 1 reaches an NTP server $0
That is the whole list no hardware to buy

This is a software tutorial. Everything happens over the network, which is exactly why NTP is the cheap upgrade every logging project should get before shipping.

Nice to have

  • A Raspberry Pi already on your network for the self-hosted NTP section (any always-on Linux box works, e.g. the Pi running Mosquitto from this site's MQTT broker tutorial)
  • Multimeter and breadboard only if you are wiring this into a larger sensor build anyway

Wiring

None. This one is pure software. If your ESP32 is part of a sensor build, the only hardware note is this: NTP needs UDP port 123 outbound, and some locked-down guest Wi-Fi networks block it. Your home network almost certainly does not.

Install

Nothing to install. configTime(), getLocalTime(), and the timezone database all ship with the ESP32 Arduino core. That is part of why this belongs in every project: the cost is one block in setup().

How NTP actually lands on the ESP32

Three pieces, in order:

  1. The sync: configTime() tells the SDK which NTP servers to ask. The ESP32 sends a UDP packet, the server replies with a 64-bit timestamp good to tens of milliseconds, and settimeofday() runs behind the scenes. Repeats hourly by default.
  2. The timezone: a POSIX TZ string like "MST7MDT,M3.2.0,M11.1.0". Read it as: base offset MST7, then the daylight rules MD, then when they start and end (second Sunday in March, first Sunday in November). The libc layer applies those rules forever, including the two hours a year nobody wants to debug at midnight.
  3. The read: getLocalTime() blocks until time is valid and hands you a filled-in struct tm. That blocking behavior is your "sync finished" check for free.

The DST rules are the whole ballgame and they live entirely inside that string. There is no "enable DST" checkbox; there is only the string. Get the string right and March forwards itself.

The code

#include <WiFi.h>
#include <time.h>

const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";

// Mountain Time: MST7 (UTC-7), DST in summer, second Sunday in March
// to first Sunday in November. Swap for your zone; see list below.
const char* TZ_RULE   = "MST7MDT,M3.2.0,M11.1.0";

// Self-hosted first: most routers run an NTP server (OpenWrt and
// pfSense do by default). Point at your router, then public fallbacks.
const char* NTP_PRIMARY  = "192.168.1.1";     // your router or Pi
const char* NTP_FALLBACK = "pool.ntp.org";

void setup() {
  Serial.begin(115200);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nWi-Fi up, IP %s\n", WiFi.localIP().toString().c_str());

  configTime(0, 0, NTP_PRIMARY, NTP_FALLBACK);  // offsets live in TZ now
  setenv("TZ", TZ_RULE, 1);
  tzset();
}

void loop() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 5000)) {   // blocks up to 5 s waiting for sync
    Serial.println("clock not synced yet");
    delay(2000);
    return;
  }
  char buf[64];
  strftime(buf, sizeof(buf), "%A %B %d %Y  %H:%M:%S %Z", &timeinfo);
  Serial.println(buf);

  // millis() still has its job: measuring durations, not naming moments.
  Serial.printf("(up %lu ms since boot)\n", (unsigned long)millis());
  delay(10000);
}

The two configTime(0, 0, ...) zeros matter: pass the UTC offset as 0 and let the TZ string own all offsets. Setting both is the classic double-offset bug (time lands off by exactly twice your zone, e.g. UTC +14 in my case, a very confusing afternoon).

Common TZ strings, ready to paste:

Zone TZ string
US Mountain MST7MDT,M3.2.0,M11.1.0
US Eastern EST5EDT,M3.2.0,M11.1.0
US Pacific PST8PDT,M3.2.0,M11.1.0
Central Europe CET-1CEST,M3.5.0,M10.5.0/3
UK GMT0BST,M3.5.0/1,M10.5.0
UTC (no DST) UTC0

The self-hosted angle

Every tutorial points at pool.ntp.org and stops. You can do better, and it costs one line: point the primary at your own network first.

  • Your router is probably already an NTP server. OpenWrt and pfSense run one by default; stock ISP routers often do too. Try its LAN IP as the primary and check the serial output: if timestamps appear, you are time-syncing from a box you own.
  • A Raspberry Pi makes a better one if you want the project-shaped version. Install chrony (sudo apt install chrony), add local stratum 8 to /etc/chrony/chrony.conf so it can serve time even without internet, then point every ESP32 in the house at it. Now timestamps keep working during internet outages, which is exactly when you care about local logs.
  • A full stratum-2 setup (Pi with GPS or a dedicated NTP appliance) is real but out of scope. What matters is the topology: the ESP32 should ask your network first, the internet second. That ordering is the self-hosted angle, and it makes every device on your LAN a little less dependent on outside services.

The fallback chain in the sketch above does that: router first, pool.ntp.org second. If your network is down, there is nothing to sync from anyway; if only the internet is down, a chrony Pi keeps every device honest.

What you learned

  • Wall-clock time on the ESP32 is three lines: configTime() for servers, setenv("TZ", ...) for the zone rules, getLocalTime() to wait for and read the result.
  • DST is not a setting, it is part of the TZ string, and libc applies the transitions for you every March and November.
  • Pointing NTP at your own router or a Pi first keeps timestamps flowing when the internet is not, and it is one line.
  • millis() measures durations; NTP names moments. A logging sketch wants both and they answer different questions.

When something breaks

  • The year prints as 1970 or 1980: the sync never completed. Check Wi-Fi actually connected, and whether the network blocks UDP 123 (guest and school networks often do). Test with a phone hotspot.
  • Time is off by exactly twice your UTC offset: you set a nonzero offset in configTime() and the TZ string also carries one. Keep the zeros in configTime() and let the TZ string own everything.
  • DST does not change in March: the TZ rule part after the comma is missing or malformed. The string must have all three parts: offset, DST name, transition dates. Compare against the table above.
  • getLocalTime() always times out on the first call right after Wi-Fi connects: the first NTP exchange takes a second or two. That is why the function blocks; give it its 5 seconds instead of retrying in a tight loop.
  • Timestamps drift minutes per day with no NTP route: the ESP32's internal oscillator is not a clock, it is a suggestion. That is the design: resync hourly, which configTime() does automatically.
  • After deep sleep the time is wrong again: RTC memory survives light sleep, not deep sleep. Re-sync on wake (the deep sleep tutorial on this site covers what does and does not survive).

What to build next

  • The ESP32 email over SMTP tutorial needs correct time more than anything: TLS certificate validation quietly depends on the clock being roughly right.
  • The NTP timestamped MQTT publishing pattern (the MQTT tutorial on this site) turns a stream of readings into something a database can actually plot.
  • The InfluxDB timeseries tutorial will reject or misorder points with bad timestamps; run this tutorial first.
  • The deep sleep tutorial pairs with this one for battery loggers that wake, sync, stamp, and sleep.

The book IoT with ESP32 bundles the connectivity tutorials including this one.


Chapter 101

ESP32: count LED pulses on your utility meter to track kWh

esp32 · 40 min

Every digital utility meter in North America blinks a small red LED as energy flows past. The marking next to it says something like "1000 imp/kWh" (pulses per kilowatt-hour) or "1 Wh/imp", and that marking is the entire API of your electric bill: count the blinks, do one division, and you know what the house is burning, exactly as the utility counts it, with no CT clamps and nothing inside the panel. This tutorial builds the counter side with an ESP32, a couple of dollars of parts, and the one piece of math (kWh per pulse) that makes the numbers real.

The trap is counting by polling. My first sketch looped, digitalRead() the sensor, looked for a rising edge, and counted. It worked on the desk with a test LED. On the real meter it undercounted by 10 to 20%, because the loop was busy printing serial and doing math at exactly the moments pulses arrived. Pulse counting is the textbook use case for a hardware interrupt: the GPIO peripheral latches the edge in silicon, even during a delay(), even during Wi-Fi traffic, and a counter variable increments between everything else. Same board, same wire, and suddenly the count matched the meter's own display.

What you need

Needed

Item Qty Purpose Est. cost
ESP32 dev board (ESP32-DevKitC or clone) 1 the brain $8-$15
Photoresistor module (LDR, e.g. LM393 comparator board) 1 sees the meter's blink LED $2
LDR (GL5528) bare, if not using a module 1 the light sensor itself $1
10K resistor 1 LDR voltage divider (bare LDR builds) $0.10
Tape or an opaque cover (e.g. a film canister) 1 blocks room light so only the LED shows $0
USB cable + phone charger 1 power near the meter on hand
Jumper wires 3 sensor to ESP32 $1

Check your meter first. You want the blinking LED marked imp/kWh (or pulses/kWh, or Wh/imp). Typical figures: 1000 imp/kWh on many residential meters, 800, 2000, or 10000 on others. The number goes straight into the code; there is no calibrating around it.

Nice to have

  • Soldering iron and solder to attach long leads to the LDR
  • Soldering iron stand, helping hands, and a soldering mat for that lead job
  • Anti-static wristband when handling the bare board
  • Magnifying goggles to read the fine imp/kWh marking on the meter face
  • Wire stripper for the sensor leads
  • Multimeter to check the sensor module's output swings when the LED blinks

How the meter talks

The LED pulses at a rate proportional to power. At 1000 imp/kWh:

  • 1000 W of load = 1000 pulses per hour = a blink every 3.6 seconds
  • 3000 W (e.g. dryer + water heater) = a blink every 1.2 seconds
  • 100 W (house idling) = a blink every 36 seconds

Two readings fall out of this, and you want both. Pulse rate is instantaneous power: pulses per second times 3600, divided by imp/kWh, gives watts. Total pulses is energy: pulses divided by imp/kWh gives kWh, which is what the bill actually charges you.

The sensor question is which end of the divider sees the blink. Most LM393 modules output LOW when they detect light, so a blink is a falling edge. Watch the serial monitor against the physical blink once before trusting the count.

Wiring

LDR module pin ESP32 pin
VCC 3.3V
GND GND
DO (digital out) GPIO 27

Power the module from 3.3V, not 5V, so its output never exceeds the ESP32's logic level. GPIO 27 is an ADC1-side pin and a plain interrupt input, and it stays far away from the boot-strapping pins (0, 2, 12, 15) that misbehave when something pulls them at reset.

Mount the sensor against the meter face over the LED with tape, then cover the whole assembly with something opaque (a film canister, a folded sticky note). Room light through the LDR's response band is dimmer than the LED but not that much dimmer; without the cover you get "pulses" every time a cloud moves.

The code

#include <WiFi.h>

const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";

const int    SENSOR_PIN   = 27;
const uint32_t IMP_PER_KWH = 1000;   // from YOUR meter's face marking
const char*   TZ_RULE      = "MST7MDT,M3.2.0,M11.1.0";

// Pulse bookkeeping (touched by the ISR, so volatile)
volatile uint32_t pulseCount = 0;
volatile uint32_t lastEdgeUs = 0;
volatile uint32_t minGapUs   = 30000;  // ignore edges closer than 30 ms

void IRAM_ATTR onPulse() {
  uint32_t now = micros();
  if (now - lastEdgeUs > minGapUs) {   // debounce against LED shimmer
    pulseCount++;
    lastEdgeUs = now;
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(SENSOR_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(SENSOR_PIN), onPulse, FALLING);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  configTime(0, 0, "192.168.1.1", "pool.ntp.org");  // router first
  setenv("TZ", TZ_RULE, 1);
  tzset();
}

void loop() {
  static uint32_t last = 0;
  delay(10000);                       // reporting window
  uint32_t windowMs = millis() - last;

  noInterrupts();
  uint32_t pulses = pulseCount;
  pulseCount = 0;
  interrupts();

  if (pulses == 0) {
    Serial.println("no pulses in window (idle or sensor aimed wrong)");
    last = millis();
    return;
  }

  float kwh_window = (float)pulses / IMP_PER_KWH;
  float watts = (pulses * 3600000.0) / (IMP_PER_KWH * windowMs);
  last = millis();

  Serial.printf("pulses %lu  this window %.4f kWh  power %.0f W\n",
                (unsigned long)pulses, kwh_window, watts);
}

The ISR does almost nothing on purpose: latch the edge, bump a counter, get out. Anything slower (serial prints, Wi-Fi) stays in loop(), and the noInterrupts() window around the counter read is a few microseconds. The 30 ms minimum gap is debounce, not because LED pulses bounce like buttons do, but because a marginal sensor aim can produce double edges on one blink.

Watts math in plain terms: each pulse is 3600000 / IMP_PER_KWH joules (1000 imp/kWh means 3600 J per pulse), so power in watts is pulses times 3600, divided by the window in seconds. If your meter prints "1 Wh/imp" instead, that is the same as 1000 imp/kWh.

What you learned

  • Hardware interrupts count pulses without polling, and the count stays exact even while Wi-Fi and serial work runs.
  • Your utility meter has had an open API all along: one LED, one imp/kWh number printed on the face, and one division.
  • Pulse rate gives live watts and the running total gives kWh; same counter, two questions.
  • ISR discipline: volatile counters, microseconds of work inside the interrupt, everything else in the loop.

When something breaks

  • Counts nothing: aim is off. Watch the serial monitor while watching the LED; if the module's onboard LED mirrors the meter's blink but the count stays zero, the sensor output is the opposite polarity and you want RISING instead of FALLING.
  • Counts roughly double: the opaque cover is missing or leaky and room light is triggering edges, or the 30 ms debounce is too long for a high-rate meter. Cover first, then check your meter's real imp/kWh figure.
  • Counts stop after days with Wi-Fi errors: a brownout or watchdog reset cleared nothing you kept in RAM. Persist the total in NVS every 100 pulses (the NVS storage tutorial on this site covers it).
  • Power figure jumps around wildly on short windows: a 10 s window with 1 or 2 pulses gives coarse watts. Report on 60 s windows for stable numbers; the math is the same.
  • Meter has no LED at all: older induction-disc meters spin a wheel instead. You are looking at the SCT-013 current transformer tutorial on this site, which measures current directly.

What to build next

  • Publish watts and kWh over MQTT (the MQTT tutorial on this site) and Home Assistant or Grafana draws your whole-house graph in an afternoon.
  • The SCT-013 current transformer tutorial measures per-circuit current to pair with this whole-house total (e.g. the meter says the house draws 900 W, the SCT-013 on the kitchen circuit says where).
  • The InfluxDB timeseries tutorial stores the kWh counters properly, and rate counters are exactly what its functions are built for.
  • Send a daily kWh summary with the SMTP email tutorial; the NTP work above is what makes "yesterday" a real window.

The book IoT with ESP32 bundles the project tutorials including this one.


Chapter 102

ESP32: talk to GPS, GSM, or another MCU over UART2

esp32 · 30 min

The GPS module sat on my desk spitting perfect NMEA sentences into the void for ten minutes before I figured out the problem. The wiring was fine. The code was fine. The baud rate was fine. The TX wire was going from the GPS's TX pin to the ESP32's TX pin, which means both devices were shouting into a wire that neither was listening to. Two transmitters on one wire receive nothing. That is the whole lesson of UART in one paragraph.

UART is the protocol for chip-to-chip chat when the other chip streams data at you: GPS modules, GSM modems (SIM800L, SIM7600), Bluetooth bridges (HC-05), RFID readers, Nextion displays, or a second microcontroller. Two data wires plus ground. No addresses, no registers, no clock line. One side transmits, the other receives, and the baud rate is the only thing they have to agree on.

The trap: the ESP32 has three hardware UARTs and beginners reach for the wrong one. UART0 is wired to the USB-serial chip that uploads your sketches and prints your Serial Monitor output. Share it with a GPS and the GPS's NMEA noise floods the upload path (e.g. a module that transmits every second can garble the boot messages and sometimes blocks flashing entirely). UART2 is the free one, and on standard dev boards it lands on GPIO 16 (RX2) and GPIO 17 (TX2).

What you need

Needed

  • ESP32 dev board (ESP32-DevKitC or a DOIT clone, about $8).
  • One UART device to talk to. Pick one:
    • NEO-6M or NEO-M8N GPS module (about $10-15), for the location demo.
    • SIM800L GSM module (about $10) with a separate 3.7-4.2 V supply; the module brownouts the ESP32 if you power it from the dev board's 3V3 pin.
    • A second ESP32 or Arduino as the other end, for the chip-to-chip demo.
  • 4 jumper wires, female-female (most breakouts have male headers).

Nice to have

  • Soldering iron + solder, if your module ships with a bare header strip.
  • Soldering iron stand, for parking the hot iron between joints.
  • Helping hands, to hold the header straight while it cools.
  • Anti-static wristband, for handling bare GSM modules.
  • Magnifying goggles, for reading the pin silkscreen on cheap breakouts (some label RX where they mean TX).
  • Soldering mat, to keep solder splashes off the desk.
  • Wire stripper, for power leads to the SIM800L.
  • Multimeter, to confirm 4 V actually reaches the GSM module under load.

Wiring

Serial is always crossed: my TX into your RX, my RX into your TX.

GPS module Connect to
VCC ESP32 3V3
GND ESP32 GND
TX ESP32 GPIO 16 (RX2)
RX ESP32 GPIO 17 (TX2)
Chip-to-chip (ESP32 to ESP32) Connect to
Board A GPIO 17 (TX2) Board B GPIO 16 (RX2)
Board A GPIO 16 (RX2) Board B GPIO 17 (TX2)
Board A GND Board B GND

The ground wire is not optional. Two devices on separate power supplies have no common voltage reference without it, and the data line reads as random garbage. Every "UART is unreliable" bug I have seen was a missing ground or a swapped TX/RX.

A SIM800L draws up to 2 A bursts during transmit. It will reset the ESP32's regulator if you share the supply. Give it its own 3.7 V lithium cell or a bench supply, and tie the grounds together.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search "TinyGPSPlus" >> install (Mikal Hart's TinyGPSPlus). It handles NMEA checksums and does not block your loop. For the SIM800L path, no library is required: the modem speaks plain AT commands over the serial line, and the code below sends them directly.

The code

GPS over UART2 (Arduino)

#include <TinyGPSPlus.h>
#include <HardwareSerial.h>

TinyGPSPlus gps;
HardwareSerial GPSSerial(2);   // UART2: RX2 = GPIO 16, TX2 = GPIO 17

void setup() {
  Serial.begin(115200);
  delay(1000);
  GPSSerial.begin(9600, SERIAL_8N1, 16, 17);   // RX=16, TX=17
  Serial.println("Waiting for fix (go near a window)...");
}

void loop() {
  // Feed every arrived byte to the parser, never wait
  while (GPSSerial.available() > 0) {
    gps.encode(GPSSerial.read());
  }

  static unsigned long lastPrint = 0;
  if (millis() - lastPrint > 1000) {
    lastPrint = millis();
    if (gps.location.isValid()) {
      Serial.print("Lat: ");
      Serial.print(gps.location.lat(), 6);
      Serial.print("  Lng: ");
      Serial.print(gps.location.lng(), 6);
      Serial.print("  Sats: ");
      Serial.println(gps.satellites.value());
    } else {
      Serial.println("No fix yet.");
    }
  }
}

The shape of this loop matters. You never "ask the GPS for data." The module streams continuously; you drain the buffer every pass through loop() and check the parsed result whenever you feel like it. That is the non-blocking UART pattern, and it is the difference between this sketch and one that freezes while waiting for a sentence.

Chip-to-chip: two ESP32s (or an ESP32 and an Arduino)

Sender (the board doing the talking):

#include <HardwareSerial.h>

HardwareSerial PeerSerial(2);

void setup() {
  Serial.begin(115200);
  PeerSerial.begin(115200, SERIAL_8N1, 16, 17);   // RX=16, TX=17
}

unsigned long lastSend = 0;
int n = 0;

void loop() {
  if (millis() - lastSend > 2000) {
    lastSend = millis();
    n++;
    PeerSerial.print("hello ");
    PeerSerial.println(n);
    Serial.print("sent: hello ");
    Serial.println(n);
  }
}

Receiver (the board doing the listening):

#include <HardwareSerial.h>

HardwareSerial PeerSerial(2);

void setup() {
  Serial.begin(115200);
  PeerSerial.begin(115200, SERIAL_8N1, 16, 17);
}

void loop() {
  if (PeerSerial.available()) {
    String line = PeerSerial.readStringUntil('\n');
    line.trim();
    if (line.length() > 0) {
      Serial.print("got: ");
      Serial.println(line);
    }
  }
}

Line-oriented framing (print on one side, readStringUntil('\n') on the other) is the simplest reliable scheme for MCU-to-MCU links. If you move binary data or variable-length payloads, graduate to a length prefix or a checksum, but do not add either until plain lines break.

GSM modems: AT commands over the same UART

A SIM800L is just a remote-controlled modem. You write an AT command, it answers. The same HardwareSerial(2) object drives it, usually at 115200 baud:

// after GPSSerial-style init at 115200:
modemSerial.println("AT");              // handshake, expect "OK"
modemSerial.println("AT+CSQ");          // signal quality, expect "+CSQ: 15,0"
modemSerial.println("AT+CMGF=1");       // text mode for SMS

Read responses with the same non-blocking drain pattern as the GPS example. Never write an AT command and blindly delay() past the reply; collect the response and check for "OK" before sending the next one.

Baud rate quick reference

Device Typical baud
GPS (NEO-6M / M8N) 9600
SIM800L / HC-05 9600 (HC-05) or 115200 (SIM800L)
Another ESP32 / Arduino 115200
Nextion display 9600 default, 115200 after config

Both ends must agree, and higher rates want shorter wires (under 30 cm at 115200 on jumper wires; 9600 tolerates a meter).

What you learned

  • UART is two crossed wires and a shared ground. My TX to your RX, always.
  • UART2 (GPIO 16/17) is the ESP32's spare hardware serial port; UART0 belongs to the USB chip and should be left alone.
  • The non-blocking pattern: drain available() bytes every loop pass, parse, then act. Never sleep waiting for a serial device.
  • Line framing (print / readStringUntil('\n')) is enough protocol for MCU-to-MCU chatter.
  • GPS, GSM, Bluetooth bridges, displays, and second MCUs are all the same pattern with different baud rates.

When something breaks

  • Nothing comes in at all. TX and RX are swapped. The GPS's TX must land on GPIO 16 (the ESP32's receive pin). Swap the two signal wires and re-test. This is the number one UART bug, and it gets everyone more than once.
  • Garbage characters instead of text. Baud rate mismatch. The sender and receiver disagree on bits per second. Confirm the rate in the device datasheet, and remember some GPS clones run 9600 while others run 115200 or 38400.
  • Data appears only while you hold a wire. Missing common ground, or a jumper wire with a broken crimp. Tie both boards' GND pins together directly and try a different jumper.
  • Sketches will not upload with the module connected. You wired the device onto UART0 (GPIO 1/3). Move it to UART2. If the module transmits during flashing, the upload collides with it.
  • readStringUntil returns partial lines. You are reading faster than the sender transmits. Either poll until the buffer has a newline, or switch to accumulating into a buffer and parsing when '\n' arrives (e.g. append Serial.read() bytes to a String and check the last character).

What to build next

  • The ESP32 GPS tutorial goes deep on NMEA parsing and UTC time from a NEO-M8N, using this exact wiring.
  • The ntfy notifications tutorial pairs with a GSM modem for alerts from somewhere without Wi-Fi (e.g. the gate at the end of a long driveway).
  • The ESP-NOW tutorial is the wireless alternative to the chip-to-chip link: same data, no wires, no baud rate.
  • The MQTT publish-subscribe tutorial takes the data this UART link delivers and fans it out to every dashboard in the house.

Chapter 103

ESP32: add login auth to your web server (session cookies, the right way)

esp32 · 40 min

The sensor dashboard from the earlier web server tutorial has a flaw that takes most people a year to notice: anyone who joins your Wi-Fi can see it. Every roommate, every guest, every laptop that ever connected. The garage door toggle page has the same flaw, which is worse, because the garage door is a physical thing. This tutorial adds login: a credentials form, a session cookie, and routes that refuse to talk to strangers.

The trap: most ESP32 "auth" tutorials hard-code a check in one handler and forget the other five. You log in, you read the temperature page, then you discover the /api/set endpoint never heard of passwords. The fix is a single check that runs on every protected route (a helper function the handlers call first), not per-route willpower. I built the per-route version once and found the unprotected endpoint three months later while showing the dashboard to a neighbor.

This is a LAN auth scheme, and honesty about its limits belongs up front. Hard-coded credentials plus rotating session tokens is fine for a home network you control. It is not fine for anything internet-facing (e.g. a port-forwarded dashboard): plain HTTP on the wire, no rate limiting, and a chip that cannot hold real secrets. For exposure beyond your router, put a reverse proxy with real TLS in front, or do not expose it at all.

What you need

Needed

  • ESP32 dev board with Wi-Fi (about $8).
  • Arduino IDE with the ESP32 board package installed.
  • The web server from the ESP32 sensor dashboard tutorial (any WebServer-based sketch works; the example here is standalone).

Nice to have

  • Multimeter, if you are wiring a physical toggle to protect (a garage door relay, e.g. the one from the relay tutorial).
  • Soldering iron + solder, only if your relay module ships bare header.
  • Iron stand and helping hands, for that header work.
  • Anti-static wristband, for handling the bare ESP32 module.
  • Magnifying goggles, for reading pin labels on relay boards.
  • Soldering mat, when the iron comes out.
  • Wire stripper, for relay wiring.
  • Breadboard and jumpers, for the relay test circuit.

Wiring

No wiring for auth itself. If you protect a physical output, the relay module wiring is:

Relay module Connect to
VCC ESP32 5V (VIN)
GND ESP32 GND
IN ESP32 GPIO 26

Relay IN pins driven by 3.3 V logic work on most opto-isolated boards. If your relay chatters or never clicks, it is a 5 V-only input; use a transistor stage or a different board.

Install

Nothing new. WebServer is built into the ESP32 Arduino core, and random tokens come from the hardware RNG through esp_random(), which is part of the core. Arduino IDE >> Tools >> Board >> confirm your ESP32 board is selected, then upload as usual.

The code

The full sketch: login form, three protected routes, session tokens with expiry, and a logout route. One user, four lines of config.

#include <WiFi.h>
#include <WebServer.h>
#include <esp_system.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

// One user. For a family, add users the same way (structs in an array).
const char* ADMIN_USER = "brian";
const char* ADMIN_PASS = "change-me-before-uploading";

WebServer server(80);

// ---- session store -------------------------------------------------
// The ESP32 cannot hold a database. It holds a few live tokens in RAM.
// Each token is a 32-bit random value; each has an expiry.
struct Session {
  uint32_t token;
  unsigned long expiresAt;
};

const int MAX_SESSIONS = 4;
Session sessions[MAX_SESSIONS];
const unsigned long SESSION_TTL_MS = 30UL * 60UL * 1000UL;  // 30 min

int findSession(uint32_t token) {
  if (token == 0) return -1;
  for (int i = 0; i < MAX_SESSIONS; i++) {
    if (sessions[i].token == token) {
      if (millis() < sessions[i].expiresAt) return i;
      sessions[i].token = 0;   // expired: evict
      return -1;
    }
  }
  return -1;
}

int newSession() {
  // Reuse a free slot, or the oldest one when the table is full
  int oldest = 0;
  for (int i = 0; i < MAX_SESSIONS; i++) {
    if (sessions[i].token == 0) return i;
    if (sessions[i].expiresAt < sessions[oldest].expiresAt) oldest = i;
  }
  return oldest;
}

// ---- auth helpers --------------------------------------------------
bool isAuthed() {
  if (!server.hasHeader("Cookie")) return false;
  String cookie = server.header("Cookie");
  int idx = cookie.indexOf("esp_session=");
  if (idx < 0) return false;
  uint32_t token = (uint32_t)strtoul(cookie.substring(idx + 12).c_str(), NULL, 16);
  return findSession(token) >= 0;
}

void requireAuth() {
  if (isAuthed()) return;
  // Send the login form instead of the protected page
  String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"
                "<title>Login</title></head>"
                "<body style='font-family:sans-serif;max-width:20rem;margin:4rem auto'>"
                "<h1>Dashboard login</h1>"
                "<form method='POST' action='/login'>"
                "<p><input name='u' placeholder='username'></p>"
                "<p><input name='p' type='password' placeholder='password'></p>"
                "<p><button type='submit'>Log in</button></p>"
                "</form></body></html>";
  server.send(401, "text/html", html);
}

void handleLogin() {
  String u = server.arg("u");
  String p = server.arg("p");
  if (u != ADMIN_USER || p != ADMIN_PASS) {
    server.send(403, "text/plain", "wrong username or password");
    return;
  }
  uint32_t token = esp_random();          // hardware RNG, never sequential
  int slot = newSession();
  sessions[slot].token = token;
  sessions[slot].expiresAt = millis() + SESSION_TTL_MS;
  // HttpOnly keeps the token away from page JavaScript
  server.sendHeader("Set-Cookie",
    "esp_session=" + String((unsigned long)token, 16) +
    "; HttpOnly; Path=/; Max-Age=1800");
  server.sendHeader("Location", "/");
  server.send(303);
}

void handleLogout() {
  if (server.hasHeader("Cookie")) {
    String cookie = server.header("Cookie");
    int idx = cookie.indexOf("esp_session=");
    if (idx >= 0) {
      uint32_t token = (uint32_t)strtoul(cookie.substring(idx + 12).c_str(), NULL, 16);
      int slot = findSession(token);
      if (slot >= 0) sessions[slot].token = 0;
    }
  }
  server.sendHeader("Set-Cookie", "esp_session=; Path=/; Max-Age=0");
  server.sendHeader("Location", "/login-page");
  server.send(303);
}

void handleLoginPage() {
  // Same form as requireAuth() sends; separate route for a clean /logout flow
  String html = "<!DOCTYPE html><html><body>"
                "<form method='POST' action='/login'>"
                "<input name='u'><input name='p' type='password'>"
                "<button>Log in</button></form></body></html>";
  server.send(200, "text/html", html);
}

void handleRoot() {
  if (!isAuthed()) { requireAuth(); return; }
  server.send(200, "text/html",
    "<h1>Protected dashboard</h1>"
    "<p>Sensor readings go here.</p>"
    "<p><a href='/logout'>Log out</a></p>");
}

void handleRelay() {
  if (!isAuthed()) { requireAuth(); return; }
  // digitalWrite(RELAY_PIN, HIGH) or whatever the protected action is
  server.send(200, "text/plain", "relay toggled");
}

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < MAX_SESSIONS; i++) sessions[i].token = 0;

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());

  // WebServer must be told to capture the Cookie header
  const char* headers[] = { "Cookie" };
  server.collectHeaders(headers, 1);

  server.on("/", handleRoot);
  server.on("/relay", handleRelay);
  server.on("/login", HTTP_POST, handleLogin);
  server.on("/login-page", handleLoginPage);
  server.on("/logout", handleLogout);
  server.onNotFound([]() {
    if (!isAuthed()) { requireAuth(); return; }
    server.send(404, "text/plain", "not found");
  });
  server.begin();
}

void loop() {
  server.handleClient();
}

Four details carry the whole scheme:

  • esp_random() tokens. A token an attacker can guess is a password they do not need. The hardware RNG makes the tokens unpredictable, and evicting the oldest session caps the table at four.
  • HttpOnly on the cookie. Without it, any XSS bug in a page you serve hands the session token to JavaScript.
  • One enforcement point. requireAuth() runs at the top of every protected handler and in onNotFound. New routes inherit the check by habit, not memory.
  • Expiry. Tokens die after 30 minutes. Without expiry, the token you emailed yourself from the office fridge laptop is a permanent key.

The honest limits

This scheme protects against a houseguest or a curious neighbor on the same Wi-Fi. It does not protect against:

  • An attacker who can read your packets. The password crosses the wire in a plain POST body. HTTP Basic auth is worse (it re-sends the password base64-encoded on every request); session cookies at least send the token, not the credential. Either way: LAN only.
  • Brute force. There is no rate limiting or lockout. The 403 reply comes as fast as requests arrive (e.g. a script hammering /login will try thousands of passwords a second on your network). A dedicated attacker inside your LAN has better options anyway; the realistic threat here is curiosity, and this stops it.
  • Multiple users with separation. This is one user with a shared password. Fine for a household, not fine for anything with roles.

If you need real security on the open internet, terminate TLS at a reverse proxy on a Raspberry Pi and keep the ESP32 behind it.

What you learned

  • Auth is a session store plus one enforcement point, not per-route checks scattered across handlers.
  • Session tokens should come from esp_random(), live in RAM with an expiry, and ride in an HttpOnly cookie.
  • WebServer needs server.collectHeaders() before it shows you the Cookie header at all.
  • The right scope for this scheme: a LAN you control. Internet-facing devices want a reverse proxy with TLS in front, or nothing exposed.
  • Logout deletes the token server-side and clears the cookie client-side; both halves matter.

When something breaks

  • Login succeeds but the dashboard still asks for credentials. The Cookie header is not being captured. You skipped server.collectHeaders(headers, 1) in setup, so isAuthed() never sees the token. Add it and re-upload.
  • Works in the browser, curl gets 401. curl does not store or resend cookies by default. Use curl -c jar.txt -d "u=brian&p=secret" http://ip/login then curl -b jar.txt http://ip/ (e.g. the cookie jar pattern from any HTTP debugging session).
  • Everyone logs out when another device logs in. Your session table is size 1 or you are reusing slot 0 every time. The newSession() function above keeps four slots and evicts only the oldest expired one.
  • Session dies long before 30 minutes. millis() wraps at about 49 days, and a token created with millis() + TTL just before the wrap fails the millis() < expiresAt check early. If your device will truly run that long, store expiry as a remaining-lifetime counter and compare with subtraction.
  • The password is in the source, and you committed it. Welcome to the honest problem with hard-coded credentials. Rotate the password, and keep the real one out of version control (e.g. a template sketch in git plus the live credentials only on the device).

What to build next

  • The HTTP server in depth tutorial shows the routing and JSON API this auth layer drops onto; protect every route it registers.
  • The mDNS tutorial gives the protected dashboard a name (garage.local) so nobody memorizes IP addresses.
  • The ntfy notifications tutorial replaces one whole class of protected pages: instead of logging into a dashboard to check status, the chip pushes the status to you.
  • The TLS in depth tutorial is the serious sibling of this one for anything that leaves your LAN.

Chapter 104

ESP32: serve a real web app from LittleFS instead of string-built HTML

esp32 · 45 min

Every ESP32 web server tutorial starts the same way: a handleRoot() function that builds HTML out of String concatenation, with escaped quotes on every line. It works, and then you want one stylesheet and one script and 200 lines of escaped quotes later you are debugging a JavaScript bug through a C++ string literal. There is a better way and it has been on the chip the whole time: LittleFS, a small filesystem that lives in the same flash as your program.

The trap I hit: I uploaded my data/ folder, got a 404 on every file, and spent an hour blaming the server code. The upload had never happened. The ESP32 Sketch Data Upload tool is a separate menu item that ships with the filesystem plugin, and if you have never installed that plugin, the menu item does not exist. Nothing warns you.

What you need

Needed

  • ESP32 dev board (any board with 4 MB flash, which is nearly all of them, about $8)
  • USB cable
  • The files you want to serve (e.g. an index.html, a style.css, and a small app.js that fetches sensor JSON)

Nice to have

  • A second ESP32 running the sensor dashboard tutorial as a data source (or just point the fetch at any JSON URL on your network)
  • Multimeter and jumper wires, only if you are wiring a real sensor to serve data from the same chip

Wiring

No wiring. This tutorial is all flash and software. The only hardware argument is the flash partition: LittleFS needs a partition table that leaves room for a filesystem, and the default 4 MB layout with "Default 4MB with spiffs" already includes one.

Install

Two installs, both one-time:

  1. Filesystem upload plugin for Arduino IDE 2.x: download the esp32fs jar from the ESP32 filesystem uploader GitHub releases, drop it into your sketchbook tools/ folder (e.g. Documents/Arduino/tools/ESP32FS/tool/esp32fs.jar), restart the IDE.
  2. Library: Arduino IDE >> Sketch >> Include Library >> Manage Libraries, search "LittleFS", install the ESP32 LittleFS by Lorol (the core also ships one, either works, be consistent).

Then create a folder named data next to your sketch, put your web files in it, and run: Arduino IDE >> Tools >> ESP32 Sketch Data Upload. That formats the partition and copies everything in data/ to flash. It takes about 30 seconds and it overwrites the whole partition every time.

The code

#include <WiFi.h>
#include <WebServer.h>
#include <LittleFS.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

WebServer server(80);

// Serve a reading so the front page has something to show.
int readSensor() {
  return 22 + random(0, 5);   // stand-in for a real sensor
}

void handleRoot() {
  File f = LittleFS.open("/index.html", "r");
  if (!f) {
    server.send(404, "text/plain", "index.html missing. Did you run Tools >> ESP32 Sketch Data Upload?");
    return;
  }
  server.streamFile(f, "text/html");
  f.close();
}

void setup() {
  Serial.begin(115200);

  if (!LittleFS.begin(true)) {   // true = format on first boot failure
    Serial.println("LittleFS mount failed, stopping.");
    while (true) delay(1000);
  }
  Serial.printf("FS used %u of %u bytes\n", LittleFS.usedBytes(), LittleFS.totalBytes());

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.print("http://");
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, handleRoot);
  server.serveStatic("/style.css", LittleFS, "/style.css");
  server.serveStatic("/app.js",    LittleFS, "/app.js");
  server.on("/api/reading", HTTP_GET, []() {
    char buf[32];
    snprintf(buf, sizeof(buf), "{\"value\":%d}", readSensor());
    server.send(200, "application/json", buf);
  });
  server.begin();
}

void loop() {
  server.handleClient();
}

And a minimal data/index.html to upload:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ESP32 app</title>
</head>
<body>
  <h1>Sensor</h1>
  <p id="value">loading...</p>
  <script src="/app.js" type="module"></script>
</body>
</html>
// data/app.js
setInterval(async () => {
  const r = await fetch('/api/reading');
  document.getElementById('value').textContent = (await r.json()).value;
}, 2000);

The pattern in one sentence: the C++ side shrinks to routes and data, the browser side becomes normal files you edit with normal tools.

The data/ upload is all-or-nothing. Change one CSS rule and you re-flash the whole partition. For iterative front-end work, serve the page from your laptop during development and only upload when the device must stand alone.

What you learned

  • LittleFS puts your web assets in flash, mounted at boot, served by the same WebServer you already know.
  • streamFile() sends a file without loading it into a String, which matters once the page is bigger than a few KB.
  • serveStatic() maps a URL path to a file and handles content types for you (it is the two-line replacement for every escaped-quote handler).
  • The data upload tool is a separate step from code upload. Forgetting it produces 404s with perfectly correct server code.

When something breaks

  • 404 on every file. The upload never happened. Run Arduino IDE

    Tools >> ESP32 Sketch Data Upload and watch for "FS starting" in the output. If that menu item is missing, the plugin jar is not installed.

  • Mount fails at boot. The board has no filesystem partition (some boards ship with "Huge App" selected, which eats the whole flash). Arduino IDE >> Tools >> Flash Size, pick 4MB with spiffs, then re-upload both the code and the data.
  • Page loads but the CSS does not. The browser cached the old file, or serveStatic() points at a path without the leading slash. Hard-refresh (e.g. Ctrl+F5) before debugging the server.
  • Everything works until you add a 300 KB image. LittleFS is fine with it, but server.send() with a String is not. Any asset over a few KB must go through streamFile() or serveStatic(), never through a String in RAM.

What to build next

Take the sensor dashboard tutorial and move its HTML into LittleFS, then add a WebSocket from the WebSocket server tutorial so the page updates live instead of polling. If your device leaves the house, the mDNS tutorial gives it a name (e.g. http://planter.local) so nobody has to memorize an IP.

The point of this tutorial is not the filesystem. It is that your ESP32 projects stop being demos with one ugly page and start being apps people can actually use.


© ctrlaltbrian.com

Published 2026-09-24 · Source: ctrlaltbrian.com

Built in the spirit of measure twice, flash once. Brian writes these so you can actually finish the project, not so you give up halfway and buy a pre-made one.

© 2026 ctrlaltbrian. Code samples are MIT. Tutorials are CC BY-NC-SA 4.0 (use them, share them, don't resell them).