Build a multi-switch useless box with a single moving arm, custom PCB, and 3D-printed mounting — full Arduino code and STL files included.
OTA (Over-The-Air) updates allow new firmware to be flashed to the ESP32 over WiFi without a USB cable. During development this removes one friction point on every iteration. For deployed devices, e.g. mounted in a ceiling, installed outdoors, or shipped to a location you cannot visit, it is the only way to update firmware at all.
This post covers ArduinoOTA, the simplest approach and the most practical for local network development. It is included in the ESP32 Arduino core, requires no additional libraries, and makes the ESP32 appear in the Arduino IDE as a network upload target alongside USB ports. HTTP OTA, which allows the ESP32 to pull firmware from a remote server and is the right approach for deployed devices, is covered briefly in the final section.
The WiFi examples below use hardcoded credentials for simplicity. For a production setup, storing credentials in NVS as covered in the ESP32 Preferences post or using the provisioning flow from the WiFi provisioning post is the better approach.
This post is part of a complete ESP32 reference you can find here.
OTA updates on the ESP32 work by maintaining two application partitions in flash, typically called OTA_0 and OTA_1. The running firmware is inside one of the two partitions. When an OTA update is received, the new firmware is written to the inactive partition. On the next reboot, the bootloader reads a small metadata partition called otadata to determine which application partition to load, and switches to the newly written one. The previously running partition remains intact.
This way a failed OTA update does not brick the ESP32. If the write is interrupted by a power loss, a WiFi drop, or a corrupted binary, the otadata entry is never updated and the bootloader continues to boot from the original partition.
{{{Fig:partitiontable}}} shows the default partition table for OTA updates when using the Arduino IDE.
As the ESP32 provides many different partition schemes, we will make sure to select one that allows for OTA. This requires the partition table to be configured with two application slots. In the Arduino IDE, we go to Tools → Partition Scheme and select any option that includes OTA, for example "Default 4MB with spiffs" or "Minimal SPIFFS (1.9MB APP with OTA)". In general, as the total flash is 4MB in size, selecting a partition scheme with 2 or more MB application memory or very large SPIFFS/FATFS is a good hint that OTA might not be enabled.
Each OTA partition must be large enough to hold the compiled firmware. With the default 4 MB partition scheme, each OTA slot is approx. 1.3 MB. If the sketch grows beyond that (e.g. because we use several large libraries), the OTA write will fail. The Arduino IDE build output shows both the partition sizes and the compiled binary size. If an OTA upload fails without a clear network reason, we can check if the binary fits the partition first.
Note: to get OTA to work, we first have to upload a OTA-capable sketch over USB.
ArduinoOTA is part of the ESP32 Arduino core. We can include it with #include <ArduinoOTA.h>. The library listens on the local network using mDNS and allows the Arduino IDE to push firmware to the device over WiFi exactly as it would over a USB connection. The device appears in the IDE under Tools → Port as a network port once it is running and connected to the same network as the pc running the Arduino IDE.
The ArduinoOTA library provides several functions, setHostname(), setPassword(), and the callbacks, that must all be called before ArduinoOTA.begin(). Calling them afterwards does not work.
Let's quickly go through each of the functions provided by ArduinoOTA before we take a look at a full working example:
ArduinoOTA.setHostname() sets the name the ESP advertises on the network. This is what appears in the Arduino IDE port list and what the IDE uses to locate the device. Without a hostname, the device falls back to a default name derived from its MAC address, which can make it difficult to identify out ESP when multiple OTA devices are on the same network.
The snippet below sets the hostname to "esp32-ota". The same hostname is also registered with mDNS, so our ESP32 becomes reachable at esp32-ota.local from any device on the network, not just the Arduino IDE.
ArduinoOTA.setHostname("esp32-ota");
ArduinoOTA.setPassword() requires the Arduino IDE to provide a matching password before accepting a firmware upload. Without a password set, anyone on the same network can push arbitrary firmware to the ESP. We will allways set a password when working with OTA.
ArduinoOTA.setPassword("otapassword");
The password uses MD5 as a hashing function. MD5 is not really considered safe anymore as it has been broken for some time now. It is however good enough to prevent anyone from uploading firmware. In production, we might want to use some other way to verify the authenticity of the firmware. This is something we touch on in the HTTP OTA section at the end of this post.
When uploading from the Arduino IDE with a password set, the IDE prompts for the password on first use of that network port. It stores the password per port, so subsequent uploads to the same device do not prompt again.
ArduinoOTA provides four callbacks that trigger at different stages of an update. In the examples below we will register them as lambda functions before calling ArduinoOTA.begin(). None are required for OTA to function, but the error callback is worth setting up since it is the primary diagnostic tool when something goes wrong.
ArduinoOTA.onStart() fires when the IDE initiates an upload. The callback receives no arguments. We can call ArduinoOTA.getCommand() inside it to determine whether the update is a firmware flash (U_FLASH) or a filesystem update. If the application has a filesystem mounted, this is the right place to unmount it before the write begins.
ArduinoOTA.onStart([]() {
String type = ArduinoOTA.getCommand() == U_FLASH ? "firmware" : "filesystem";
Serial.println("OTA start: " + type);
});
ArduinoOTA.onEnd() fires when the transfer completes successfully, just before the device reboots into the new firmware. This is the right place to flush any state or close open resources that should not be interrupted mid-update.
ArduinoOTA.onEnd([]() {
Serial.println("OTA complete. Rebooting...");
});
ArduinoOTA.onProgress() fires repeatedly during the transfer. It receives the number of bytes transferred so far and the total size. During development this is useful to confirm the upload is progressing; on a deployed device with no serial connection it serves no practical purpose.
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progress: %u%%\n", progress * 100 / total);
});
ArduinoOTA.onError() fires when the update fails. It receives an ota_error_t value. The five error codes cover: authentication failure (OTA_AUTH_ERROR), failure to begin the OTA write (OTA_BEGIN_ERROR), failure to establish the connection (OTA_CONNECT_ERROR), failure during data transfer (OTA_RECEIVE_ERROR), and failure to finalise the write (OTA_END_ERROR). In practice, OTA_AUTH_ERROR means a wrong password was provided, and OTA_BEGIN_ERROR almost always means the compiled binary is too large for the OTA partition.
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA error [%u]: ", error);
if (error == OTA_AUTH_ERROR) Serial.println("Auth failed");
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin failed");
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect failed");
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive failed");
else if (error == OTA_END_ERROR) Serial.println("End failed");
});
In the examples above, we used serial output. If the ESP is in some unreachable location or simply tricky to get to, we might want to display the callback info in the webui hosted on the ESP itself or use the built in LED.
ArduinoOTA.begin() starts the OTA service. We call it in setup() after the WiFi connection is established and after all configuration calls and callbacks have been registered. Once called, the device begins advertising itself on the network and is ready to accept uploads.
ArduinoOTA.begin();
Note that begin() starts mDNS internally using the hostname set with setHostname(). There is no need to call MDNS.begin() separately. If the application also runs a web server that uses mDNS, as in the ESP32 web server post, calling MDNS.begin() a second time after ArduinoOTA.begin() will not cause an error but is redundant.
ArduinoOTA.handle() processes incoming OTA connections. We call it at the top of every loop() iteration. When no OTA activity is pending it returns immediately, so its overhead at idle is negligible.
void loop() {
ArduinoOTA.handle();
// application code
}
The critical constraint is that handle() must be reached on every loop iteration without a long interruption. If the loop() blocks, e.g. because of a large delay() or a while loop waiting on a sensor, the OTA connection may time out, failing the upload. The Arduino IDE reports this as a generic upload error with no indication that a blocking loop is the cause, which makes it one of the more frustrating issues to diagnose the first time it happens.
We can fix this by replacing blocking waits with millis()-based timing. Instead of holding the CPU for a period, we check when the last task ran and whether the interval time has passed on each iteration.
void loop() {
ArduinoOTA.handle();
static unsigned long lastTask = 0;
if (millis() - lastTask >= 1000) {
// sensor reads, state updates, etc.
lastTask = millis();
}
}
Note: handle() can only work if the WiFi connection is active. If the application disconnects from WiFi and does not reconnect, OTA will not be reachable until the connection is back up again. A reconnect loop on WiFi disconnect is probably a good idea for any device that relies on OTA updates.
Once the sketch is running on the ESP32 with ArduinoOTA initialised and the device connected to WiFi, open the Arduino IDE and navigate to Tools → Port. The device appears as a network port labelled with the hostname and its IP address, for example esp32-ota at 192.168.1.42. Select it as you would a USB port and click Upload.
On the first upload to a password-protected device, the IDE prompts for the OTA password. Enter the password set with setPassword(). The IDE stores it for that port so subsequent uploads do not prompt again.
If the device does not appear in the port list, the most common causes are: the device and the computer are not on the same network, mDNS is blocked by a firewall or network policy, or the device is in a blocking state and handle() is not being reached. To debug the issue, we can connect to the Serial output, check the webui output or use the LED given the OTA callbacks.
The following is a complete working sketch combining everything we discussed above. It connects to WiFi, configures ArduinoOTA with a hostname and password, registers all four callbacks, and runs a non-blocking millis()-based loop alongside the OTA handler. We can use this sketch as a good starting point for any project that needs OTA.
#include <WiFi.h>
#include <ArduinoOTA.h>
const char* WIFI_SSID = "MyWiFi";
const char* WIFI_PASSWORD = "MyPassword";
const char* OTA_PASSWORD = "otapassword";
const char* OTA_HOSTNAME = "esp32-ota";
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected. IP: ");
Serial.println(WiFi.localIP());
ArduinoOTA.setHostname(OTA_HOSTNAME);
ArduinoOTA.setPassword(OTA_PASSWORD);
ArduinoOTA.onStart([]() {
String type = ArduinoOTA.getCommand() == U_FLASH ? "firmware" : "filesystem";
Serial.println("OTA start: " + type);
});
ArduinoOTA.onEnd([]() {
Serial.println("OTA complete. Rebooting...");
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progress: %u%%\n", progress * 100 / total);
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA error [%u]: ", error);
if (error == OTA_AUTH_ERROR) Serial.println("Auth failed");
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin failed");
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect failed");
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive failed");
else if (error == OTA_END_ERROR) Serial.println("End failed");
});
ArduinoOTA.begin();
Serial.println("OTA ready. Hostname: " + String(OTA_HOSTNAME));
}
void loop() {
ArduinoOTA.handle();
static unsigned long lastTask = 0;
if (millis() - lastTask >= 1000) {
// application code here
lastTask = millis();
}
}
ArduinoOTA requires the device and the computer performing the upload to be on the same local network. This works during development but not for a device deployed at a remote location, behind NAT, or embedded in a product that ships to an end user. For those cases, HTTP OTA is the appropriate approach.
HTTP OTA reverses the direction of the transfer. Rather than the IDE pushing firmware to the device, the device fetches a firmware binary from a URL and flashes itself. The ESP32 Arduino core provides HTTPUpdate.h for this. Because the device initiates the connection outbound, it works over the internet from behind any network configuration without requiring inbound port forwarding or a fixed IP address.
The binary is the compiled firmware exported from the Arduino IDE using Sketch → Export Compiled Binary, which produces a .bin file in the sketch folder. That file is hosted on an HTTP or HTTPS server. The device fetches it with httpUpdate.update(), writes it to the inactive OTA partition, and reboots into it on success.
HTTP OTA can be made automatic by adding a version check. The device periodically polls a lightweight endpoint that returns the current firmware version number. If the version is newer than the one running on the device, the update is fetched and applied. This is the standard pattern for managing a fleet of deployed devices without physical access to any of them.
Automatic updates require careful implementation. A misconfigured server, a corrupted binary, or a compromised endpoint can push bad firmware to every device in a fleet simultaneously. The minimum requirements for any production setup are: the binary must be served over HTTPS with a valid certificate, the device must verify the binary against a checksum or signature before applying it, and the rollback mechanism built into the ESP32 bootloader must be enabled so a failed update falls back to the previous firmware rather than leaving the device in an unbootable state. The ESP32 Arduino core documentation covers HTTPUpdate.h and the rollback API in detail.
This project (with exceptions) is published under the CC Attribution-ShareAlike 4.0 International License.