ESP32 OTA Firmware Updates: ArduinoOTA and HTTP Pull Updates

Once an ESP32 is screwed inside a junction box or mounted on a water tank, connecting a USB cable for every bug fix is not practical. Over-the-air (OTA) updates let the device receive new firmware over Wi-Fi. For a product with many devices, OTA is not optional: it is how you fix bugs and security issues after deployment.

This tutorial covers two methods. ArduinoOTA lets you upload from the Arduino IDE to a board on the same network, which is ideal during development. HTTP pull updates let devices check a web server for a newer version and update themselves, which scales to a fleet. You will also see how the flash partitions make OTA safe.

How OTA Works on the ESP32

The ESP32's flash is divided into partitions described by a partition table. OTA-capable layouts have two application slots, app0 (ota_0) and app1 (ota_1), plus a small otadata partition that records which slot to boot.

+-----------+---------+----------------+----------------+--------+
| nvs       | otadata | app0 (ota_0)   | app1 (ota_1)   | spiffs |
| settings  | boot sel| running fw     | new fw written |  files |
+-----------+---------+----------------+----------------+--------+
  1. The running firmware in app0 downloads the new image and writes it into app1 while continuing to run.
  2. When the download completes and the image is verified, otadata is updated to point to app1.
  3. The ESP32 reboots into app1. The next update goes into app0, and so on.
  4. If the download fails midway, otadata is unchanged and the old firmware keeps running.

The Arduino IDE's default partition scheme for most ESP32 boards already includes two app slots. Because each slot is only part of the flash, your sketch must fit in one slot. Check Tools > Partition Scheme; schemes labelled "No OTA" give a bigger single app but cannot update over the air.

Method 1: ArduinoOTA from the IDE

Upload this sketch once over USB. After that, the board appears under Tools > Port as a network port named after its hostname, and later uploads go over Wi-Fi. The computer and ESP32 must be on the same subnet, because discovery uses mDNS.

ArduinoOTA with hostname, password and progress callbacks
#include <WiFi.h>
#include <ArduinoOTA.h>

const char* WIFI_SSID = "YourWiFi";
const char* WIFI_PASS = "YourWiFiPassword";

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  ArduinoOTA.setHostname("tank-node");       // name shown in Tools > Port
  ArduinoOTA.setPassword("ota-pass-123");     // IDE asks for this on upload

  ArduinoOTA.onStart([]() {
    // stop motors, close files, switch relays to a safe state here
    Serial.println("OTA start");
  });
  ArduinoOTA.onProgress([](unsigned int done, unsigned int total) {
    Serial.printf("Progress: %u%%", done * 100 / total);
    Serial.println();
  });
  ArduinoOTA.onEnd([]() { Serial.println("OTA done, rebooting"); });
  ArduinoOTA.onError([](ota_error_t e) {
    Serial.printf("OTA error %u", e);    // auth, begin, connect, receive, end
    Serial.println();
  });
  ArduinoOTA.begin();
  Serial.println("Ready, IP " + WiFi.localIP().toString());
}

void loop() {
  ArduinoOTA.handle();     // must be called often
  // ... your normal application code, without long delay() calls ...
}

Method 2: HTTP Pull Updates for a Fleet

For deployed devices, it is better if the device asks the server for updates. Host two files on any web server: version.txt containing a number such as 3, and firmware.bin exported from the IDE with Sketch > Export Compiled Binary. Every hour the device compares the server version with its own and, if newer, downloads and installs the binary with the core's HTTPUpdate library.

ESP32 checks version.txt and self-updates with httpUpdate
#include <WiFi.h>
#include <HTTPClient.h>
#include <HTTPUpdate.h>

const int FW_VERSION = 2;   // increase this in every release
const char* VERSION_URL = "http://192.168.1.50:8000/version.txt";
const char* BIN_URL     = "http://192.168.1.50:8000/firmware.bin";

uint32_t lastCheck = 0;

int serverVersion() {
  WiFiClient c;
  HTTPClient http;
  int v = -1;
  if (http.begin(c, VERSION_URL) && http.GET() == 200) {
    v = http.getString().toInt();
  }
  http.end();
  return v;
}

void checkForUpdate() {
  int v = serverVersion();
  Serial.printf("Running v%d, server v%d", FW_VERSION, v);
  Serial.println();
  if (v <= FW_VERSION) return;

  WiFiClient client;
  httpUpdate.rebootOnUpdate(true);           // restart into the new image
  t_httpUpdate_return r = httpUpdate.update(client, BIN_URL);
  switch (r) {
    case HTTP_UPDATE_FAILED:
      Serial.println("Update failed: " + httpUpdate.getLastErrorString());
      break;
    case HTTP_UPDATE_NO_UPDATES:
      Serial.println("No update");
      break;
    case HTTP_UPDATE_OK:
      Serial.println("Updated");             // not reached if rebooting
      break;
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.begin("YourWiFi", "YourWiFiPassword");
  while (WiFi.status() != WL_CONNECTED) delay(500);
  checkForUpdate();                          // check once at boot
}

void loop() {
  if (millis() - lastCheck > 3600000UL) {    // then every hour
    lastCheck = millis();
    checkForUpdate();
  }
}

The sketch uses a plain integer for the version so the comparison is a simple >. Version strings like "1.2.10" need careful parsing, because a text comparison would rank "1.2.9" above "1.2.10". Checking at boot and then hourly means a freshly powered device picks up fixes immediately, while a running fleet does not hammer the server. In a larger system you can replace the timer with an MQTT command such as home/node1/ota so updates happen only when you decide, and add a random delay of a few minutes per device so a hundred nodes do not download at the same second.

For a quick test server, open a terminal in the folder containing both files and run python -m http.server 8000. Build version 3 of the sketch (change FW_VERSION to 3), export the binary, copy it to the folder, write 3 into version.txt, and watch the version 2 board update itself.

Choosing a Method

AspectArduinoOTAHTTP pull update
Who starts itDeveloper, from the IDEDevice, on a schedule or command
NetworkSame LAN as the PCAnywhere the server is reachable
ScaleOne board at a timeWhole fleet
Best forDevelopment and bench testingDeployed devices
SecurityPassword on the OTA portHTTPS and signed images recommended

Pitfalls and Good Practice

  • Network port missing in the IDE: mDNS is blocked, the PC is on a different subnet, or the firewall blocks the IDE. Try rebooting the board and allowing the IDE through the firewall.
  • Not enough space error: the sketch is larger than one app slot. Choose a partition scheme with larger OTA slots, if your flash size allows.
  • Bricking with bad firmware: a new image that crashes at boot or never connects to Wi-Fi cannot fetch the fix. Test on one device first and roll out gradually.
  • Plain HTTP in production: anyone on the path could serve malicious firmware. Use HTTPS with a pinned CA certificate, and consider signed binaries and ESP-IDF's app rollback feature, which marks a new image valid only after it confirms it works.
  • Actuators during update: the device spends many seconds flashing. Put relays and motors in a safe state in the start callback.

Summary

OTA works because the ESP32 keeps two application slots and switches between them only after a complete, verified download. ArduinoOTA gives fast wireless uploads during development, and httpUpdate with a version file lets a fleet update itself from a server. Combine OTA with HTTPS and careful staged rollouts, and your deployed nodes stay maintainable for years.