ESP32 OTA updates: push firmware wirelessly in production
- 1 ESP32 GPIO explained: inputs, outputs, pull-ups and interrupts
- 2 How to use ESP32 timers: stop using delay() in your firmware
- 3 ESP32 MQTT tutorial: publish and subscribe with PubSubClient
- 4 ESP32 FreeRTOS: tasks, queues and semaphores in plain English
- 5 ESP32 watchdog timer: build firmware that recovers from crashes
- 6 ESP32 OTA updates: push firmware wirelessly in production
You’ve shipped 200 devices. They’re installed in panel boxes at customer sites, some on the other side of the country. You find a bug in the MQTT reconnect logic. Without OTA, your options are: send a technician to each site, or ask the customer to plug in a USB cable. Neither is acceptable. OTA isn’t a nice-to-have — it’s a requirement for any firmware that leaves the building.
How ESP32 OTA works: the partition scheme
The ESP32 flash is divided into partitions defined in a partition table. For OTA, the key partitions are two app slots: app0 and app1 (sometimes called ota_0 and ota_1). The default OTA partition scheme looks like this:
| Partition | Type | Size | Purpose |
|---|---|---|---|
| nvs | Data | 20KB | Non-volatile storage (WiFi credentials, etc.) |
| otadata | Data | 8KB | Tracks which app slot is active |
| app0 (ota_0) | App | 1.9MB | Active firmware slot |
| app1 (ota_1) | App | 1.9MB | Standby firmware slot |
| spiffs | Data | 1MB | Filesystem (optional) |
During OTA, new firmware is written to the inactive slot (the one not currently running). Once the write is complete and verified, the otadata partition is updated to mark the new slot as active. On next boot, the bootloader reads otadata and boots from the newly written slot.
If the new firmware fails to run (or fails to mark itself as valid), the bootloader detects the failure on next boot and rolls back to the previous slot. This is why OTA is safe even if the new firmware crashes on startup — you get your old firmware back.
Critical: The rollback mechanism depends on the new firmware calling
esp_ota_mark_app_valid_cancel_rollback()after it has successfully started and verified its own health. If your firmware doesn’t call this, it will roll back even if it was working fine. More on this below.
ArduinoOTA: fine for development, wrong for production
ArduinoOTA is what most tutorials show. It listens for OTA pushes from the Arduino IDE or espota.py over the LAN. It’s convenient for development:
#include <ArduinoOTA.h>
void setup() {
ArduinoOTA.begin();
}
void loop() {
ArduinoOTA.handle(); // Must be called regularly
}
The problems with this in production:
- No security by default. Anyone on the same network can push any firmware to any ESP32 running ArduinoOTA without authentication.
- Requires IDE/toolchain on the deployment machine. You can’t push updates from your server.
- No version checking. No way to know if the device already has the latest firmware.
- No rollback coordination. If the update fails mid-write, you find out when the device stops responding.
For production, you want HTTP OTA: the device checks a version endpoint, compares with its current version, and downloads new firmware only if an update is available.
HTTP OTA: the right approach
The flow:
- Device boots, checks
https://your-update-server.com/firmware/version.json - If reported version > current version, download
https://your-update-server.com/firmware/esp32-device.bin - Write to inactive OTA slot, verify MD5
- Reboot into new firmware
- New firmware performs health checks, then calls
esp_ota_mark_app_valid_cancel_rollback()
Versioning
Store your version in firmware as a simple semantic version string:
// In a shared header: version.h
#define FIRMWARE_VERSION_MAJOR 1
#define FIRMWARE_VERSION_MINOR 4
#define FIRMWARE_VERSION_PATCH 2
#define FIRMWARE_VERSION_STR "1.4.2"
The version endpoint returns JSON:
{
"version": "1.4.3",
"url": "https://your-update-server.com/firmware/esp32-sensor-1.4.3.bin",
"md5": "a3f2c8e1b4d7a9f0c2e6b8d3f5a7c1e4",
"mandatory": false
}
Production OTA implementation
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
#include <ArduinoJson.h>
#include "version.h"
const char* OTA_VERSION_URL = "https://updates.yourserver.com/firmware/version.json";
// This certificate is for your update server — not a global CA bundle
// Generate with: openssl s_client -connect updates.yourserver.com:443 < /dev/null 2>&1 | \
// openssl x509 -outform PEM
const char* SERVER_CERT = R"(
-----BEGIN CERTIFICATE-----
MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
... (your actual cert here) ...
-----END CERTIFICATE-----
)";
struct OTAInfo {
char version[32];
char url[256];
char md5[33];
bool mandatory;
};
// Parse version string "X.Y.Z" into comparable integer X*10000 + Y*100 + Z
uint32_t parseVersion(const char* v) {
unsigned int major = 0, minor = 0, patch = 0;
sscanf(v, "%u.%u.%u", &major, &minor, &patch);
return major * 10000 + minor * 100 + patch;
}
bool checkForUpdate(OTAInfo& info) {
HTTPClient http;
WiFiClientSecure secureClient;
secureClient.setCACert(SERVER_CERT);
if (!http.begin(secureClient, OTA_VERSION_URL)) {
Serial.println("[OTA] Failed to begin HTTP");
return false;
}
http.setTimeout(10000);
int code = http.GET();
if (code != 200) {
Serial.printf("[OTA] Version check failed, HTTP %d\n", code);
http.end();
return false;
}
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, http.getStream());
http.end();
if (err) {
Serial.printf("[OTA] JSON parse error: %s\n", err.c_str());
return false;
}
strlcpy(info.version, doc["version"] | "", sizeof(info.version));
strlcpy(info.url, doc["url"] | "", sizeof(info.url));
strlcpy(info.md5, doc["md5"] | "", sizeof(info.md5));
info.mandatory = doc["mandatory"] | false;
uint32_t currentVer = parseVersion(FIRMWARE_VERSION_STR);
uint32_t newVer = parseVersion(info.version);
if (newVer <= currentVer) {
Serial.printf("[OTA] Already up to date (%s)\n", FIRMWARE_VERSION_STR);
return false;
}
Serial.printf("[OTA] Update available: %s → %s\n", FIRMWARE_VERSION_STR, info.version);
return true;
}
bool performOTA(const OTAInfo& info) {
Serial.printf("[OTA] Downloading from %s\n", info.url);
HTTPClient http;
WiFiClientSecure secureClient;
secureClient.setCACert(SERVER_CERT);
if (!http.begin(secureClient, info.url)) {
Serial.println("[OTA] HTTP begin failed");
return false;
}
http.setTimeout(30000);
int code = http.GET();
if (code != 200) {
Serial.printf("[OTA] Download failed, HTTP %d\n", code);
http.end();
return false;
}
int contentLength = http.getSize();
if (contentLength <= 0) {
Serial.println("[OTA] Unknown content length");
http.end();
return false;
}
Serial.printf("[OTA] Firmware size: %d bytes\n", contentLength);
if (!Update.begin(contentLength)) {
Serial.printf("[OTA] Not enough space: %s\n", Update.errorString());
http.end();
return false;
}
if (info.md5[0] != '\0') {
Update.setMD5(info.md5);
}
WiFiClient* stream = http.getStreamPtr();
size_t written = Update.writeStream(*stream);
http.end();
if (written != (size_t)contentLength) {
Serial.printf("[OTA] Write mismatch: wrote %u of %d bytes\n", written, contentLength);
Update.abort();
return false;
}
if (!Update.end()) {
Serial.printf("[OTA] Update.end() failed: %s\n", Update.errorString());
return false;
}
Serial.printf("[OTA] Update complete. Written %u bytes.\n", written);
return true;
}
void checkAndApplyOTA() {
OTAInfo info = {};
if (!checkForUpdate(info)) {
return; // No update needed or check failed
}
// Don't run OTA if sensor data is being actively collected and sent
// In a real system, you'd coordinate with other tasks via a semaphore
// to pause operations before starting OTA
for (int attempt = 1; attempt <= 3; attempt++) {
Serial.printf("[OTA] Attempt %d/3\n", attempt);
if (performOTA(info)) {
Serial.println("[OTA] Rebooting into new firmware...");
delay(1000);
ESP.restart();
}
delay(5000); // Wait 5 seconds between retry attempts
}
Serial.println("[OTA] All attempts failed. Continuing with current firmware.");
}
Marking firmware as valid (preventing unintended rollback)
In your new firmware’s setup(), after verifying your critical systems work:
#include <esp_ota_ops.h>
void setup() {
Serial.begin(115200);
// ... initialize hardware, connect WiFi, etc. ...
if (WiFi.status() == WL_CONNECTED && mqtt.connect("device-id")) {
// All critical systems work — mark this firmware as valid
esp_ota_mark_app_valid_cancel_rollback();
Serial.println("[Boot] Firmware marked valid");
} else {
// Something is broken — don't mark valid
// The watchdog will eventually reset the device
// On the next boot, the bootloader will roll back to the previous firmware
Serial.println("[Boot] Health check failed — rollback will occur on next reset");
}
}
What NOT to do
Don’t run OTA while actively reading sensors and publishing. OTA writes to flash, and the flash controller is shared with the instruction cache. Flash writes during normal operation can cause cache misses, instruction stalls, and in extreme cases, data corruption. The safest approach: suspend other tasks or enter a dedicated OTA mode.
Don’t use plain HTTP for OTA in production. Anyone who can sniff your network traffic can intercept and replace your firmware binary with malicious code. Use HTTPS with certificate pinning (verify the server’s certificate against a known-good value, not just “is it a valid certificate from some CA”).
Don’t trigger OTA during power-up. The first 30 seconds after power-up is when power quality is least stable (capacitors charging, inrush current from other equipment). An OTA that starts 2 seconds after boot and hits a brownout mid-write will corrupt the partition. Wait until the system is stable.
Don’t version-check on every boot. Polling your update server every 30 seconds from 200 devices will hammer your server and drain battery on battery-powered devices. Check once on startup, then periodically (every few hours) during operation.
What’s next
Once you have OTA working, you have the deployment infrastructure sorted. The harder problem is making the firmware itself robust — especially the connection management logic that ensures your device is always in a known, recoverable state. The ESP32 state machine post shows how to replace the rat’s nest of boolean flags and nested if/else with a proper finite state machine that makes illegal states structurally impossible.
Related posts
Comments
Enjoyed this tutorial?
Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.