Why Firmware Updates Matter
Every firmware has bugs. Every project evolves. Every security vulnerability eventually needs a patch. For an ESP32 sitting on a desk connected by USB cable, firmware updates are trivial — click Upload. For an ESP32 installed in a weather-proof enclosure on a rooftop, inside a wall, at a remote agricultural site, or distributed across hundreds of customer devices, physically connecting a USB cable for each update is impractical or impossible.
Firmware update capability — OTA (Over-The-Air) updates — should be designed into every ESP32 project that will leave your desk. This guide covers how ESP32 OTA works, the standard ArduinoOTA library for development use, the ElegantOTA web interface for end-user updates, and the more robust esp_https_ota approach for production firmware with rollback protection.
Understanding the Dual-Partition OTA Architecture
The ESP32’s OTA system depends on having two separate application partitions in flash. The standard OTA partition scheme on a 4 MB device allocates:
| Name | Type | Offset | Size |
|---|---|---|---|
| nvs | data/nvs | 0x9000 | 20 KB |
| otadata | data/ota | 0xe000 | 8 KB |
| app0 (OTA_0) | app/ota_0 | 0x10000 | 1.9 MB |
| app1 (OTA_1) | app/ota_1 | 0x1F0000 | 1.9 MB |
| spiffs | data/spiffs | 0x3D0000 | 192 KB |
The otadata partition is 8 KB and contains a small record indicating which app partition (app0 or app1) is currently active and whether it has been verified. During OTA: the currently running firmware writes incoming bytes to the inactive partition; when the download completes and the CRC verifies correctly, it updates the otadata to mark the new partition as boot target; the device reboots; the new firmware runs and must call esp_ota_mark_app_valid_cancel_rollback() to confirm it is working — if it does not (because it crashed), the watchdog timer eventually triggers and the bootloader reverts to the previous partition.
Method 1: ArduinoOTA — Development Use
ArduinoOTA is the simplest OTA implementation, included with the ESP32 Arduino core. After the initial USB flash, subsequent updates come from the Arduino IDE over Wi-Fi. Include OTA support in your sketch:
#include <WiFi.h>
#include <ArduinoOTA.h>
const char* SSID = "YourSSID";
const char* PASS = "YourPassword";
const char* OTA_PASS = "your-ota-password"; // use a strong password
void setup() {
Serial.begin(115200);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.printf("nConnected: %sn", WiFi.localIP().toString().c_str());
ArduinoOTA.setHostname("esp32-sensor-01"); // identifies board in IDE port list
ArduinoOTA.setPassword(OTA_PASS);
ArduinoOTA.onStart([]() {
String type = ArduinoOTA.getCommand() == U_FLASH ? "sketch" : "filesystem";
Serial.println("OTA start: updating " + type);
});
ArduinoOTA.onEnd([]() { Serial.println("nOTA complete. Rebooting..."); });
ArduinoOTA.onProgress([](unsigned int done, unsigned int total) {
Serial.printf("Progress: %u%%r", done * 100 / total);
});
ArduinoOTA.onError([](ota_error_t err) {
Serial.printf("OTA Error[%u]: ", err);
if (err == OTA_AUTH_ERROR) Serial.println("Auth Failed");
else if (err == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
else if (err == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
else if (err == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
else if (err == OTA_END_ERROR) Serial.println("End Failed");
});
ArduinoOTA.begin();
Serial.println("OTA ready");
}
void loop() {
ArduinoOTA.handle(); // must call every loop iteration
// ... your application code ...
}
After uploading this via USB, the board appears under Tools → Port in Arduino IDE as a network device (e.g., “esp32-sensor-01 at 192.168.1.x”). Select it and upload subsequent sketches wirelessly. Ensure every future sketch also includes ArduinoOTA.handle() in loop() — without it, the next OTA will fail and you must revert to USB.
Method 2: ElegantOTA — Browser-Based Updates
ElegantOTA adds a web page at /update on your ESP32’s IP address. Any browser on the same network can open it, select a .bin file, and upload. Install via the Arduino Library Manager: search “ElegantOTA” by Ayush Sharma.
#include <WiFi.h>
#include <WebServer.h>
#include <ElegantOTA.h>
WebServer server(80);
void setup() {
Serial.begin(115200);
WiFi.begin("SSID", "Password");
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println("IP: " + WiFi.localIP().toString());
server.on("/", []() {
server.send(200, "text/plain", "ESP32 Sensor Node v1.0");
});
ElegantOTA.begin(&server, "admin", "strongpassword");
server.begin();
}
void loop() {
server.handleClient();
ElegantOTA.loop();
}
Open http://192.168.1.x/update in any browser. A clean upload page accepts a .bin file. This is the recommended approach for end-user firmware updates — your users do not need Arduino IDE, PlatformIO, or any developer tools.
Method 3: esp_https_ota — Production-Grade OTA
For production firmware, OTA updates should come from your own server over HTTPS with SSL certificate validation. The ESP-IDF esp_https_ota component handles this:
#include "esp_https_ota.h"
#include "esp_ota_ops.h"
#define FIRMWARE_URL "https://updates.yourserver.com/firmware/esp32-v1.2.0.bin"
// Your server's root CA certificate (embed as a C string)
extern const uint8_t server_cert_pem_start[] asm("_binary_server_cert_pem_start");
extern const uint8_t server_cert_pem_end[] asm("_binary_server_cert_pem_end");
void perform_ota_update(void) {
esp_http_client_config_t config = {
.url = FIRMWARE_URL,
.cert_pem = (char*)server_cert_pem_start,
.timeout_ms = 10000,
.keep_alive_enable = true,
};
esp_https_ota_config_t ota_config = { .http_config = &config };
esp_err_t ret = esp_https_ota(&ota_config);
if (ret == ESP_OK) {
ESP_LOGI("OTA", "OTA complete — rebooting");
esp_restart();
} else {
ESP_LOGE("OTA", "OTA failed: %s", esp_err_to_name(ret));
}
}
The certificate validation ensures only your server can deliver firmware — a compromised network cannot inject rogue updates. Combined with Secure Boot and Signed OTA, this creates a trust chain from the chip’s ROM all the way to your build server.
Implementing Rollback Protection
Automatic rollback prevents a bad OTA update from permanently bricking a device. After a firmware update and reboot, the new firmware has a window (defined by the watchdog timeout) to call esp_ota_mark_app_valid_cancel_rollback(). If it does not — because it crashed, hung, or failed to connect — the bootloader reverts to the previous firmware on the next reset.
#include "esp_ota_ops.h"
void setup() {
// ... initialise everything ...
// Connect to Wi-Fi
WiFi.begin(SSID, PASS);
if (WiFi.waitForConnectResult(10000) == WL_CONNECTED) {
// Everything critical works — mark this firmware as valid
esp_ota_mark_app_valid_cancel_rollback();
Serial.println("Firmware validated — rollback cancelled");
} else {
// Failed — do NOT mark valid; rollback will occur on next reset
Serial.println("Wi-Fi failed — NOT marking firmware valid");
delay(5000);
ESP.restart(); // trigger rollback to previous firmware
}
}
Tracking Firmware Versions
Every deployed firmware should identify itself with a version string. Define it at compile time and report it on boot and in any status messages:
#define FW_VERSION "2.1.4"
#define FW_BUILD __DATE__ " " __TIME__
void setup() {
Serial.printf("Firmware: v%s (built %s)n", FW_VERSION, FW_BUILD);
// Report version via MQTT
char payload[128];
snprintf(payload, sizeof(payload),
"{"version":"%s","build":"%s","ip":"%s"}",
FW_VERSION, FW_BUILD, WiFi.localIP().toString().c_str());
mqttClient.publish("devices/esp32-01/status", payload, true); // retained
}
Your backend can subscribe to the status topic and maintain a registry of device-version pairs. When a new firmware is released, query the registry to find all devices still on older versions and trigger OTA updates programmatically — scaling from one device to thousands with no manual intervention.