10/21/2024
ESP32 OTA updates — firmware updates without touching the device
TL;DR: ESP32 supports OTA (Over-the-Air) updates over WiFi. Arduino OTA via ArduinoIDE/PlatformIO or HTTP OTA from your own server. The device updates without physical access. Essential when an ESP32 is in a hard-to-reach place — on a roof, in an electrical panel, or embedded in a wall.
You deploy a temperature sensor to 20 locations. A month later you discover a bug in the calibration logic. Without OTA — a physical visit to each device, unmounting it, plugging in a USB cable. With OTA — upload a new binary to the server, all devices download and restart within an hour.
Types of OTA on ESP32
Arduino OTA is the mechanism built into the Arduino framework for ESP32. The device announces itself via mDNS on the local network. ArduinoIDE or PlatformIO sees it as a target port and can upload firmware wirelessly. Downside: requires the IDE on the same network, doesn’t work over the internet, and requires the device to be in “listening” mode for 30 seconds after boot.
HTTP OTA is the production approach. The ESP32 itself initiates a connection to your HTTP server, checks for a new version, downloads the .bin file, writes it to the second flash partition, and restarts with the new firmware. Works over the internet, requires no tooling on the developer side beyond an HTTP server.
ESP-IDF OTA is the low-level API for advanced projects — custom partition handling, cryptographic firmware signing. For most Arduino projects, HTTP OTA is more than sufficient.
HTTP OTA — code
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
const char* FIRMWARE_URL = "http://192.168.10.200:8080/firmware/esp32_sensor.bin";
const char* VERSION_URL = "http://192.168.10.200:8080/firmware/version.txt";
const char* CURRENT_VERSION = "1.0.3";
void performOTA() {
HTTPClient http;
// Check current version on server
http.begin(VERSION_URL);
int httpCode = http.GET();
if (httpCode != 200) {
http.end();
return;
}
String serverVersion = http.getString();
serverVersion.trim();
http.end();
if (serverVersion == CURRENT_VERSION) {
Serial.println("Firmware up to date: " + serverVersion);
return;
}
Serial.println("Updating: " + CURRENT_VERSION + " -> " + serverVersion);
// Download and flash firmware
http.begin(FIRMWARE_URL);
httpCode = http.GET();
if (httpCode == 200) {
int contentLength = http.getSize();
WiFiClient* stream = http.getStreamPtr();
if (Update.begin(contentLength)) {
size_t written = Update.writeStream(*stream);
if (written == contentLength && Update.end()) {
Serial.println("OTA success. Restarting...");
ESP.restart();
} else {
Update.printError(Serial);
}
}
}
http.end();
}
void setup() {
Serial.begin(115200);
WiFi.begin("SSID", "password");
while (WiFi.status() != WL_CONNECTED) delay(500);
performOTA(); // check for update on boot
}
For HTTPS connections to a private server, you need to add the CA certificate or use setInsecure() (not for production — only for local networks). For a public server with Let’s Encrypt, load the DigiCert root CA into the ESP32 flash.
Firmware server (Python Flask)
A simple server handling ESP32 requests:
from flask import Flask, send_file, jsonify
import os
app = Flask(__name__)
FIRMWARE_DIR = "/opt/firmware"
@app.route("/firmware/version.txt")
def version():
with open(f"{FIRMWARE_DIR}/version.txt") as f:
return f.read().strip(), 200, {'Content-Type': 'text/plain'}
@app.route("/firmware/esp32_sensor.bin")
def firmware():
return send_file(
f"{FIRMWARE_DIR}/esp32_sensor.bin",
mimetype="application/octet-stream",
as_attachment=True,
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Deploying a new version: compile in PlatformIO (pio run), copy firmware.bin to the server, update version.txt. All devices will pick up the new version on their next check (e.g. every 24 hours or after each reboot).
Partitioning (dual OTA)
ESP32 has a default partition scheme with two OTA slots: app0 and app1. On boot, the bootloader checks which slot is active and boots from it.
When you call Update.begin(), the new firmware is written to the inactive slot. Only after Update.end() + restart does the bootloader switch to the new slot. If the new firmware doesn’t start correctly (crashes within the first 3 restarts), esp-idf automatically rolls back to the previous slot.
To activate rollback in code, you must call esp_ota_mark_app_valid_cancel_rollback() after a successful startup (e.g. after establishing a WiFi connection and sending the first heartbeat to the server). If you don’t call this function, the system will consider the firmware invalid and revert to the previous version.
Pitfalls
Don’t complicate OTA with signed-key encryption for home projects — it adds complexity without practical benefits when the server is internal. Always check the version before downloading — without this, every time the ESP32 boots it would download several hundred kilobytes unnecessarily.
A watchdog timer is a must: if new firmware hangs the ESP32 in an infinite loop instead of crashing, the hardware watchdog forces a reset after 8 seconds. In the Arduino Framework, the watchdog is enabled by default — don’t disable it. If your code has long blocking operations, add esp_task_wdt_reset() at key points.
Be mindful of partition sizes. The default default scheme gives ~1.3MB per OTA partition. If your compiled firmware is larger (e.g. a project with BLE + WiFi + TLS), you’ll need to use the large_spiffs scheme or write a custom partition CSV.
Summary
HTTP OTA on ESP32 is a one-day investment that pays back with every subsequent firmware update. Key elements: version check before downloading, Flask or nginx as the binary server, dual-partition rollback for safety, and watchdog timer as the last line of defense. For projects with more than 5 devices in the field — an absolute necessity.