Today we’re working with something that will change the way you manage your ESP32 projects: Over-The-Air (OTA) firmware updates. OTA is the ability to push new code to a microcontroller wirelessly – no USB cable required.
Introduction
If you’ve spent any time working with ESP32s, you’re familiar with the usual development cycle. First, you connect the board via USB, compile your sketch in the Arduino IDE, and then click Upload. During initial project development, this method makes sense, as having the ESP32 on your workbench alongside the Arduino IDE is convenient.
But once you deploy your project, updates become a bit trickier. Yes, you can “uninstall” the ESP32 and bring it back to the workbench when it needs an update, or it may be easier to bring a notebook computer with the Arduino IDE to the ESP32. Both methods are awkward and unprofessional!
OTA changes all that. Once you configure your sketch with OTA code, you can update it from anywhere on your network.

OTA updates become especially powerful once your ESP32s are deployed in the real world. Imagine a home automation network with half a dozen ESP32s scattered around – one monitoring the garage temperature, another controlling irrigation in the garden, and a third monitoring the front door. Without OTA, fixing a bug means physically tracking down each board, plugging it in, and flashing it. That’s tedious at best, and completely impractical when boards are sealed inside enclosures or tucked behind walls. With OTA, you update all of them from your desk.
In this article, we’ll explore three distinct OTA methods, each suited to a different scenario. The first method lets you upload directly from the Arduino IDE over Wi-Fi, just as naturally as you’d upload via USB. The second method has the ESP32 host its own web page with a file-upload form (I’ll show you how to make a .bin file to flash teh ESP32 with). And teh third method has the ESP32 check a local file server for a newer version and download it automatically.
Each method has its own strengths and ideal use cases, and we’ll cover them all in detail. But first, let’s see how the ESP32 performs OTA updates.
How ESP32 OTA Updates Work
The key to understanding the OTA update process is to examine the ESP32’s Flash memory partition table. A snapshot of the Flash memory of a Seeduino XIAO ESP32-S3 is shown here:

There are three partitions central to the OTA process.
- The app0 (OTA0) partition – This is the primary application slot — this is where your sketch lives after an initial USB upload.
- The app1 (OTA1) partition – This is the secondary application slot, where incoming OTA firmware is written during an update.
- The otadata partition – This is a small metadata area that tracks which of the two application partitions is currently active. It acts like a pointer, telling the bootloader which slot to run at startup.
Here is how the partitions are used:

Before an update, the ESP32 is running code from app0; the otadata partition points to app0 as the active slot.

When a new OTA update begins, new firmware starts uploading via Wi-Fi while the ESP32 continues running the existing app0 code without interruption. The new firmware is written into the idle app1 partition.

Once the download is complete and verified, otadata is updated to point to app1, and the ESP32 reboots into the new code.
On the next update, the process reverses: new firmware goes into app0, and after a successful update, otadata flips back to app0. This alternating cycle continues indefinitely.
One important thing to remember is that every OTA firmware version must include OTA code. If you flash a sketch without OTA support, the board loses its wireless update capability and must be recovered via USB. Keep the OTA code in every firmware release.
Hardware and Experiment Setup
I will be using an ESP32-S3 for the experiments today, specifically a Seeeduino XIA ESP32-S3 board. This board has 8 MB of Flash memory, which will allow for two suitably large OTA partitions. The board uses a dual-core Xtensa LX7 processor running at 240 MHz and also has 8 MB of PSRAM.
To make the hookup easier, I have chosen to use the Seeduino XIAO Expansion Board V1, which is available from the same retailer that sold you the Seeduino XIAO ESP32-S3. This handy expansion board turns the XIAO into an all-in-one development platform, providing a built-in SSD1306 OLED display (128×64 pixels) for status messages, a push button ready to use, LiPo battery support with a charging circuit (essential for powering the ESP32 without USB), a real-time clock with battery backup, and Grove connectors and DuPont headers for easy peripheral attachment.
If you don’t have the expansion board, all you need is a standard SSD1306 OLED display (the common 128×64 I²C variant) and a momentary push button. Wire everything up as shown below:

The same hardware hookup is used for all three demo sketches.
Software and Library Setup
All three sketches are written for the Arduino IDE using the ESP32 board package.
Select the Seeeduino XIAO ESP32-S3 in the board manager and make sure you’re using a partition scheme that supports OTA – the default XIAO ESP32-S3 setup provides an OTA-capable layout, but it’s always worth confirming this in the Tools menu.
The Wi-Fi, OTA, and HTTP libraries used in these sketches are all included with the ESP32 Arduino core and require no additional installation.
The only libraries you may need to add via the Library Manager are the Adafruit SSD1306 and Adafruit GFX libraries for the OLED display. Search for “Adafruit SSD1306” in the Library Manager; it will automatically prompt you to install the GFX dependency.
Now that we have our ESP32 attached to our workstation and the IDE configured correctly, we can begin with our first OTA method.
Method 1 — ArduinoOTA

The ArduinoOTA method is the simplest method for anyone who already uses the Arduino IDE. Once the OTA-enabled sketch is running on the board, the ESP32 shows up as a network port in the Arduino IDE, right alongside your regular USB COM ports. You simply select it and upload, exactly as you would via USB.
One thing to note is that the wireless connection cannot be used for the Arduino IDE Serial Monitor.
ArduinoOTA Demo
We will demonstrate ArduinoOTA using a simple sketch that prints to the OLED and flashes the XIAO’s built-in LED. After uploading it to the XIAO and verifying proper operation, we will remove the USB cable and connect to the XIAO via wireless. We will then modify the sketch and perform an OTA update directly from the Arduino IDE.
Here is the sketch we will be using to demonstrate ArduinoOTA:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
/* ESP32 OTA Update Demo - ArduinoOTA 1_ArduinoOTA.ino Demonstrates ESP32 OTA using Arduino OTA Library Uses Seeeduino XIAO ESP32-S3 + XIAO Expansion Board V1 Load via USB, modify and then load via Arduino IDE Network Connection DroneBot Workshop 2026 https://dronebotworkshop.com */ // Include required Libraries #include <WiFi.h> #include <ESPmDNS.h> #include <WiFiUdp.h> #include <ArduinoOTA.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> // Network Credentials const char* ssid = "YOUR SSID"; const char* password = "YOUR PASSWORD"; // Demo Variables (Change these for the OTA upload!) String versionNumber = "v1.0 (USB)"; int blinkDelay = 1000; // LED Blink Time in milliseconds // Hardware Definitions #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 // Initialize the OLED display Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // Non-blocking timer variables unsigned long previousMillis = 0; bool ledState = LOW; void setup() { Serial.begin(115200); // Initialize the onboard LED pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, ledState); // Initialize I2C utilizing the official Seeed Studio GPIO numbering // XIAO ESP32-S3 SDA = GPIO 5, SCL = GPIO 6 Wire.begin(5, 6); // Initialize the OLED (Standard 0x3C address) if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F("SSD1306 allocation failed")); } else { display.clearDisplay(); display.setTextSize(2); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 20); display.print(versionNumber); display.display(); } // --- Network Setup --- WiFi.mode(WIFI_STA); // CRITICAL FIX FOR ESP32-S3 TRANSFER SPEEDS // Prevents the radio from sleeping and dropping UDP packets WiFi.setSleep(false); WiFi.begin(ssid, password); while (WiFi.waitForConnectResult() != WL_CONNECTED) { Serial.println("Connection Failed! Rebooting..."); delay(5000); ESP.restart(); } // --- ArduinoOTA Setup --- ArduinoOTA.setHostname("XIAO-ESP32S3"); ArduinoOTA.onStart([]() { display.clearDisplay(); display.setCursor(0, 20); display.setTextSize(1); display.print("Updating Firmware..."); display.display(); }); ArduinoOTA.onEnd([]() { display.clearDisplay(); display.setCursor(0, 20); display.print("Update Complete!"); display.display(); }); // WARNING: Do NOT update the OLED inside onProgress! // I2C calls here will bottleneck the CPU and stall the OTA transfer. ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { Serial.printf("Progress: %u%%\r", (progress / (total / 100))); }); ArduinoOTA.onError([](ota_error_t error) { Serial.printf("Error[%u]: ", error); }); ArduinoOTA.begin(); } void loop() { // Must be called frequently to handle incoming OTA requests ArduinoOTA.handle(); // Non-blocking LED blink logic unsigned long currentMillis = millis(); if (currentMillis - previousMillis >= blinkDelay) { previousMillis = currentMillis; ledState = !ledState; digitalWrite(LED_BUILTIN, ledState); } } |
Key Code Concepts
The ESP32 must be in station mode, connecting to your existing Wi-Fi network rather than creating its own access point. This is set with WiFi.mode(WIFI_STA).
A critical entry is WiFi.setSleep(false), which keeps the Wi-Fi radio awake at all times. Without this, the radio enters a power-saving sleep mode between packets and will miss incoming OTA connections, causing slow or completely failed uploads. This is one of the most common sources of ArduinoOTA problems, particularly on the ESP32-S3.
Each device should have a unique hostname set with ArduinoOTA.setHostname(). This is what appears in the Arduino IDE port list, allowing you to distinguish between multiple boards on the same network.
Before calling ArduinoOTA.begin() you must register your onStart, onEnd, onProgress, and onError callbacks. In this sketch, we set them to display status on the OLED and log progress to Serial.
Finally, ArduinoOTA.handle() must be called at the very top of every loop() iteration. This is what lets the library receive and process incoming OTA connection requests.
Use Non-Blocking Code!
This is one of the most important things to understand when working with any type of OTA update.
You will note that we do not use the delay() function to blink our LED. The delay() function completely blocks the microcontroller for its entire duration, and nothing else runs, including ArduinoOTA.handle(). If an OTA upload request arrives while the MCU is inside a delay(), it will be missed entirely, and the update will fail or time out.
The demo sketch blinks the onboard LED using millis() rather than delay() – the LED state is toggled only when enough time has elapsed, leaving the processor free to handle OTA requests between checks.
You’ll need to make sure not to use any blocking code statements in a design that uses OTA.
ArduinoOTA Testing
Load the sketch to the Arduino using the conventional USB cable method. You should see the version number printed on the OLED and observe the LED flashing once per second.
Now disconnect the USB cable and power the ESP32 with an alternate power supply. I used a LiPo battery connected to the Expansion Board, but you could also use a USB power supply.
Make sure your workstation is on the same Wi-Fi network as the ESP32; this is critical!
Look in the ports of the Arduino IDE. You should see a new “Network” section, with a wireless connection available. If you don’t see it, try closing and opening the Arduino IDE. If you are on Windows, you may need to allow the connection through your Windows Firewall.
After connecting to the wireless network, you may need to tell the Arduino IDE that the board is a XIAO ESP32-S3. After you do that, you can use the connection just as you would a USB connection.
Make some edits to the sketch:
- Change the version number to “1.1”
- Change the blink delay to “300”
Now save and upload the sketch.
The first time you upload, you will be prompted for a password. You can enter any character you like in the password box to continue. A password is not required, but the Arduino IDE stores one in case it is.

The upload should proceed as usual, except for a different font color in the IDE.
Once the upload is completed, the ESP32 will reset itself and display the new version number. You will also note the LED blinking much faster.

The ArduinoOTA method has advantages and disadvantages, as shown here:

If all you want to do is use the Arduino IDE to perform occasional OTA updates, the ArduinoOTA method may be all you need.
Method 2 — Web OTA

Web OTA takes a completely different approach to updating the ESP32 over the air. Instead of requiring the Arduino IDE on the update computer, the ESP32 itself hosts a small web server with a firmware-upload form. Any device with a web browser on the same network can trigger an update, with no programming environment required.
This makes it an excellent option for distributing firmware updates to customers, colleagues, or less-technical users with whom you’d rather not share your source code.
Web OTA .bin File
The firmware update file is a binary or “.bin” file. You can easily generate one using the Arduino IDE.
- Make edits with the IDE and save your sketch (make sure to retain the OTA code in the new version).
- Open the Sketch menu at the top of the Arduino IDE.
- Select “Export Compiled Binary“.
The sketch will compile as usual. Once it finishes compiling, it will just stop; no message will be displayed other than the standard compiler messages.
So where is the output? Go to your sketch folder (usually under your Arduino folder), and you should see a “build” folder. Inside this folder you will see several files, including many with “.bin” extensions.

The one you want is the sketchname.ino.bin file. If you are using the example sketch, the file will be named 2_WebOTA.ino.bin. This is the file you will use to upload your updates via the web page created by the ESP32.
Web OTA Sketch
Here is the sketch that we will be using to demonstrate Web OTA:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
/* ESP32 OTA Update Demo - Web OTA 2_WebOTA.ino Demonstrates ESP32 OTA using Web Server & Update Libraries Uses Seeeduino XIAO ESP32-S3 + XIAO Expansion Board V1 Load via USB, update with .bin file via Web Upload DroneBot Workshop 2026 https://dronebotworkshop.com */ // Include required libraries #include <WiFi.h> #include <WebServer.h> #include <Update.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> // USER CONFIGURATION const char* WIFI_SSID = "YOUR SSID"; const char* WIFI_PASSWORD = "YOUR PASSWORD"; // ---------------------------------------- // ---------- VERSION / APPEARANCE ---------- // CHANGE THESE FOR THE UPDATE DEMO (v1.1) const char* VERSION = "v1.0"; const char* DEVICE_NAME = "XIAO Web OTA"; const char* PAGE_ACCENT = "#6495ed"; // change to e.g. "#e05c8a" for v1.1 // ------------------------------------------ // OLED display #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 #define OLED_ADDR 0x3C Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); WebServer server(80); unsigned long bootMillis = 0; void showStatus(const String& line1, const String& line2) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("Web OTA Demo"); display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); display.setCursor(0, 18); display.println(VERSION); display.setCursor(0, 36); display.println(line1); display.setCursor(0, 50); display.println(line2); display.display(); } String uptimeString() { unsigned long s = (millis() - bootMillis) / 1000; unsigned long h = s / 3600; s %= 3600; unsigned long m = s / 60; s %= 60; char buf[16]; sprintf(buf, "%lu:%02lu:%02lu", h, m, s); return String(buf); } // ---- Status page with upload form ---- String buildPage() { String html; html += "<!DOCTYPE html><html><head><meta charset='utf-8'>"; html += "<meta name='viewport' content='width=device-width,initial-scale=1'>"; html += "<title>" + String(DEVICE_NAME) + "</title><style>"; html += "body{font-family:Arial,sans-serif;background:#1e2d3d;color:#f0f0f0;"; html += "margin:0;padding:24px;}"; html += ".card{max-width:480px;margin:0 auto;background:#26384b;border-radius:8px;"; html += "padding:20px 24px;border-top:5px solid " + String(PAGE_ACCENT) + ";}"; html += "h1{margin:0 0 4px 0;color:" + String(PAGE_ACCENT) + ";}"; html += ".row{display:flex;justify-content:space-between;padding:6px 0;"; html += "border-bottom:1px solid rgba(255,255,255,0.1);}"; html += "input[type=file]{margin:12px 0;color:#f0f0f0;}"; html += "button{background:" + String(PAGE_ACCENT) + ";color:#fff;border:0;"; html += "padding:10px 18px;border-radius:4px;font-size:15px;cursor:pointer;}"; html += "progress{width:100%;height:18px;margin-top:12px;}"; html += "</style></head><body><div class='card'>"; html += "<h1>" + String(DEVICE_NAME) + "</h1>"; html += "<div class='row'><span>Firmware</span><b>" + String(VERSION) + "</b></div>"; html += "<div class='row'><span>IP Address</span><b>" + WiFi.localIP().toString() + "</b></div>"; html += "<div class='row'><span>Uptime</span><b>" + uptimeString() + "</b></div>"; html += "<h3>OTA Firmware Update</h3>"; html += "<form method='POST' action='/update' enctype='multipart/form-data' id='f'>"; html += "<input type='file' name='update' accept='.bin'><br>"; html += "<button type='submit'>Upload & Flash</button>"; html += "<progress id='p' value='0' max='100' style='display:none'></progress>"; html += "</form>"; html += "<script>"; html += "var f=document.getElementById('f');"; html += "f.addEventListener('submit',function(e){"; html += "e.preventDefault();var fd=new FormData(f);"; html += "var x=new XMLHttpRequest();var p=document.getElementById('p');"; html += "p.style.display='block';"; html += "x.upload.addEventListener('progress',function(ev){"; html += "if(ev.lengthComputable){p.value=(ev.loaded/ev.total)*100;}});"; html += "x.onload=function(){document.body.innerHTML="; html += "'<div class=card><h1>Update done</h1><p>Rebooting... ';"; html += "document.body.innerHTML+='reload in a few seconds.</p></div>';"; html += "setTimeout(function(){location.href='/';},6000);};"; html += "x.open('POST','/update');x.send(fd);});"; html += "</script>"; html += "</div></body></html>"; return html; } void handleRoot() { server.send(200, "text/html", buildPage()); } // ---- OTA upload handler ---- void handleUpdateDone() { bool ok = !Update.hasError(); server.send(200, "text/plain", ok ? "OK" : "FAIL"); delay(500); if (ok) ESP.restart(); } void handleUpdateUpload() { HTTPUpload& upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { Serial.printf("Update: %s\n", upload.filename.c_str()); showStatus("OTA Update", "Receiving..."); if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_WRITE) { if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { Update.printError(Serial); } } else if (upload.status == UPLOAD_FILE_END) { if (Update.end(true)) { Serial.printf("Update Success: %u bytes\n", upload.totalSize); showStatus("OTA Update", "Complete!"); } else { Update.printError(Serial); showStatus("OTA Error", "Failed"); } } } void setup() { Serial.begin(115200); delay(300); bootMillis = millis(); Wire.begin(); if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { Serial.println("SSD1306 init failed"); } showStatus("Connecting WiFi", "..."); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Connecting to WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); Serial.print("IP address: "); Serial.println(WiFi.localIP()); server.on("/", HTTP_GET, handleRoot); server.on("/update", HTTP_POST, handleUpdateDone, handleUpdateUpload); server.begin(); Serial.print("Version: "); Serial.println(VERSION); Serial.println("Web server started"); showStatus("IP: " + WiFi.localIP().toString(), "Web server up"); } void loop() { server.handleClient(); // Refresh uptime on the OLED once per second static unsigned long last = 0; if (millis() - last > 1000) { last = millis(); showStatus("IP: " + WiFi.localIP().toString(), "Up " + uptimeString()); } } |
The sketch uses the ESP32’s built-in WebServer and Update libraries.
The buildPage() function dynamically assembles the HTML status page, pulling in the current firmware version, IP address, uptime counter, and the PAGE_ACCENT color variable. The upload form posts the binary file to the /update endpoint via an XHR request with a progress event listener, which drives the progress bar in the browser.
Two key server handlers drive the OTA process. handleUpdateUpload() is called repeatedly as binary data chunks arrive; it feeds those chunks to the Update library with Update.write().
When the upload is complete, handleUpdateDone() sends a response to the browser and, if the update was successful, calls ESP.restart() to reboot into the new firmware. The Update.end(true) call in the upload handler verifies the written image before the reboot proceeds.
Demonstrating Web OTA
Load the sketch to the ESP32 using a USB cable (you could also load it via the OTA connection if you are still connected from the first experiment). Note that the screen displays the version number, IP address, and uptime.

Now disconnect the USB cable (if you used one) and power the ESP32 with an alternate power source.
Open a web browser on a computer connected to the same Wi-Fi network as the ESP32, then go to the IP address displayed on the OLED. You should see an upload page.

We will need a binary file to upload to update our ESP32. Back in the Arduino IDE, edit the following parameters in the sketch:
- The version number
- The screen color (use the suggested color or one of your own)
Now save the sketch and compile it to a binary file using the instructions provided earlier. Use the Choose File button on the upload web page to select the correct .bin file. Then click the Upload and Flash button to start the update. You will see a progress bar while the upload is occurring.
When the upload is done, the ESP32 will restart. Note the version number on the OLED, and the color of the web page ( you may need to manually refresh the web page).

As with the previous method, there are pros and cons to using Web OTA:

This is a great way to update the ESP32 project you build and distribute to others. All you need to do is provide a .bin file to update or refresh the firmware.
Method 3 — Server Pull OTA
Server Pull OTA is the most professional of the three methods and the one most commonly used in production IoT systems. Instead of an update being pushed to the ESP32 from outside, the ESP32 itself reaches out to a file server, checks whether a newer version is available, and, if so, downloads and installs it automatically.
The file server hosts just two files: latest.txt, which contains the current version string (e.g., 1.1), and firmware.bin, which is the compiled application binary. When an update check is triggered, the ESP32 fetches the latest.txt file, compares the version string to its LOCAL_VERSION constant, and calls the update routine only if the server version is newer.
In our demo, the check is triggered by pressing the hardware button, but the same checkForUpdate() function can just as easily be called at boot, on a timer, or at any other point in your application logic, making the entire process fully automatic.
Setting Up the Python File Server
Any computer on your local 2.4 GHz Wi-Fi network can act as the file server. We’ll use Python’s built-in HTTP server module, which requires zero configuration and serves files from any folder you point it at.
Python 3 is pre-installed on most Linux and macOS systems. On Windows, download the installer from python.org and run it (make sure to tick “Add Python to PATH” during setup), and reboot after installation before proceeding.
Follow these steps to get the server running. C
- Create a dedicated folder to serve files (the sketch download from dronebotworkshop.com includes a ready-made server subfolder for this purpose).
- Inside that folder, create a plain text file named latest.txt. Its entire content should be just the version string: 1.0 — no quotes, no extra lines.
- Open a terminal (Linux/macOS) or Command Prompt (Windows) and navigate to that folder. Most operating systems let you open the terminal directly in the folder using your file manager.
- Find your machine’s IP address on the Wi-Fi network using ipconfig on Windows or ifconfig on Linux/macOS. Look for the IPv4 address on your wireless adapter, not any Ethernet or virtual adapter addresses.
Then start the HTTP server with the following command.
|
1 |
python3 -m http.server 8080 |
Test it by opening a browser on any device on the network and navigating to http://<your-ip>:8080. You should see a directory listing showing latest.txt. If you open that file, it should show 1.0. Your server is ready.

Now that we have a web server, we can concentrate on the code we will use to work with it.
Server-Pull OTA Code
The sketch for using Server-Pull OTA is a bit more complex than the previous two; however, it is pretty easy to understand once you break it into sections.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
/* ESP32 OTA Update Demo - Local Server Pull 3_ServerPull.ino Demonstrates automatic ESP32 OTA updates Uses Seeeduino XIAO ESP32-S3 + XIAO Expansion Board V1 Requires local File Server Load via USB, checks file server for updates automatically DroneBot Workshop 2026 https://dronebotworkshop.com */ // Include required libraries #include <WiFi.h> #include <HTTPClient.h> #include <HTTPUpdate.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> // USER CONFIGURATION const char* WIFI_SSID = "YOUR SSID"; const char* WIFI_PASSWORD = "YOUR PASSWORD"; // IP address of the computer running the Python web server, // and the port you started it on. const char* SERVER_HOST = "YOUR SERVER IP ADDRESS"; // <-- your PC's LAN IP const int SERVER_PORT = 8080; // ---------------------------------------- // ---------- VERSION ---------- // CHANGE THIS FOR THE UPDATED FIRMWARE (v1.1) THAT YOU PLACE // ON THE SERVER AS firmware.bin const char* LOCAL_VERSION = "1.0"; // ----------------------------- #define CHECK_ON_BOOT false // set true to auto-check at startup // OLED #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 #define OLED_ADDR 0x3C Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // Pushbutton (D1 = GPIO1, active LOW) #define BUTTON_PIN D1 String baseURL() { return "http://" + String(SERVER_HOST) + ":" + String(SERVER_PORT); } void showStatus(const String& line1, const String& line2) { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("Server Pull OTA"); display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); display.setCursor(0, 18); display.println("Ver " + String(LOCAL_VERSION)); display.setCursor(0, 36); display.println(line1); display.setCursor(0, 50); display.println(line2); display.display(); } // Keep only version characters (digits and dots). This strips a UTF-8 BOM // (Windows Notepad adds the hidden bytes EF BB BF to the start of a file), // trailing newlines/spaces, and any other stray control characters that // would otherwise corrupt the version comparison. String sanitizeVersion(const String& raw) { String clean = ""; for (size_t i = 0; i < raw.length(); i++) { char c = raw.charAt(i); if ((c >= '0' && c <= '9') || c == '.') clean += c; } return clean; } // Fetch the version string from the server (latest.txt). // Uses the explicit-WiFiClient form of begin() (more robust on current // ESP32 cores) and retries once, since the first HTTP request after boot // occasionally returns -1 while the connection is still settling. String fetchServerVersion() { WiFiClient client; HTTPClient http; String url = baseURL() + "/latest.txt"; http.begin(client, url); int code = http.GET(); if (code <= 0) { // transient failure - retry once Serial.printf("latest.txt GET failed (%d), retrying...\n", code); http.end(); delay(200); http.begin(client, url); code = http.GET(); } String ver = ""; if (code == HTTP_CODE_OK) { ver = http.getString(); ver.trim(); ver = sanitizeVersion(ver); // strip BOM / whitespace / junk bytes } else { Serial.printf("latest.txt GET failed, code %d\n", code); } http.end(); return ver; } // Returns true if "server" is newer than "local". // Simple dotted-numeric compare (e.g. 1.0 vs 1.1 vs 2.0). bool isNewer(const String& server, const String& local) { int sMaj = 0, sMin = 0, lMaj = 0, lMin = 0; sscanf(server.c_str(), "%d.%d", &sMaj, &sMin); sscanf(local.c_str(), "%d.%d", &lMaj, &lMin); if (sMaj != lMaj) return sMaj > lMaj; return sMin > lMin; } void doUpdate() { String url = baseURL() + "/firmware.bin"; Serial.println("Downloading: " + url); showStatus("Downloading", "update..."); WiFiClient client; // Throttled progress feedback. Printing to serial every packet is cheap, // but a full OLED redraw over I2C is slow and competes with the download, // so the screen is refreshed only every 10%. httpUpdate.onProgress([](int cur, int total) { unsigned int pct = total ? (cur * 100) / total : 0; Serial.printf("Update progress: %u%%\r", pct); static int lastShownPct = -1; if (pct != (unsigned int)lastShownPct && pct % 10 == 0) { lastShownPct = pct; showStatus("Downloading", String(pct) + " %"); } }); httpUpdate.rebootOnUpdate(true); t_httpUpdate_return ret = httpUpdate.update(client, url); switch (ret) { case HTTP_UPDATE_FAILED: Serial.printf("\nUpdate failed (%d): %s\n", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); showStatus("Update FAILED", "see serial"); break; case HTTP_UPDATE_NO_UPDATES: Serial.println("\nNo updates"); showStatus("No update", ""); break; case HTTP_UPDATE_OK: // Board reboots automatically; this line rarely prints Serial.println("\nUpdate OK - rebooting"); break; } } void checkForUpdate() { Serial.println("Checking for update..."); showStatus("Checking", "server..."); if (WiFi.status() != WL_CONNECTED) { showStatus("WiFi down", ""); return; } String serverVer = fetchServerVersion(); if (serverVer.length() == 0) { showStatus("Server", "unreachable"); return; } Serial.println("Current version: " + String(LOCAL_VERSION)); Serial.println("Server version: " + serverVer); if (isNewer(serverVer, LOCAL_VERSION)) { Serial.println("Update available. Downloading update..."); showStatus("Update avail!", serverVer); delay(800); doUpdate(); } else { Serial.println("Up to date."); showStatus("Up to date", "Srv " + serverVer); } } void setup() { Serial.begin(115200); delay(300); pinMode(BUTTON_PIN, INPUT_PULLUP); Wire.begin(); if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { Serial.println("SSD1306 init failed"); } showStatus("Connecting WiFi", "..."); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Connecting to WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); Serial.print("IP address: "); Serial.println(WiFi.localIP()); Serial.println("Current version: " + String(LOCAL_VERSION)); showStatus("Ready", "Press BTN"); if (CHECK_ON_BOOT) checkForUpdate(); } void loop() { // Trigger a check when the pushbutton is pressed (active LOW) static bool lastBtn = HIGH; bool btn = digitalRead(BUTTON_PIN); if (lastBtn == HIGH && btn == LOW) { delay(30); // debounce if (digitalRead(BUTTON_PIN) == LOW) { checkForUpdate(); } } lastBtn = btn; delay(10); } |
The sketch is organized into several helper functions, each handling one part of the update pipeline.
fetchServerVersion() opens an HTTP connection to baseURL() + “/latest.txt” using an explicit WiFiClient object. It includes a single automatic retry because the first HTTP request after boot occasionally returns -1 while the connection is still settling.
The returned string is passed through sanitizeVersion(), which strips everything except digits and dots. These can become an issue when you use text editors like Notepad to edit the version file.
isNewer() performs a proper dotted-numeric version comparison using sscanf() to parse the major and minor version numbers separately, so 1.9 is correctly identified as older than 1.10 (a comparison that would fail with a simple string equality check).
doUpdate() calls the httpUpdate library to stream firmware.bin directly into the inactive flash partition, with an onProgress callback that throttles OLED redraws to every 10% (updating the display on every packet would stall the transfer, since full I²C redraws are slow. Every 10% is still visually pleasing).
checkForUpdate() ties all of these together: it calls fetchServerVersion(), compares the result to LOCAL_VERSION, and either triggers doUpdate() or reports that the board is already up to date.
Testing the Server-Pull OTA
As with the other two OTA demonstrations, we will start by uploading the sketch to the XIAO ESP32-S3 using a USB cable (it is also possible to use one of the previous OTA methods). Make sure the ESP32 is on the same network as the web server and that you can access it at the designated IP address (don’t forget the “8080” at the end of the URL).

In my demonstration, I left the USB cable connected to the ESP32 to monitor the Serial Monitor traffic.
Test the board by pressing the pushbutton. This will cause the ESP32 to read the version file on the server and compare it to its own internal version. At the beginning, it should be version 1.0, so you should see a message on the OLED and Serial Monitor confirming that the ESP32 is at the latest version.
Now make some changes to the file; the version number is sufficient. Compile this into a binary file. Copy that binary file to the web server and rename it to firmware.bin (that last step is very important).
You also need to edit the latest.txt file to show the latest version. Change the 1.0 to 1.1.
Now repeat the test on the ESP32. When you press the pushbutton, the ESP32 should check for the latest version and see if one is available. It should then download the update and restart the ESP32. The progress will be shown on both the OLED and the Serial monitor.
In our demonstration, the check for updates was initiated with the pushbutton, but you could just as easily have used a timer to do this automatically at a preset interval.
Conclusion
Using the ESP32’s OTA Update capabilities will really take your projects to the next level. With minimal code and setup, your ESP32 projects can continue to benefit from updates and improvements for years. And if you’re tasked with keeping a fleet of ESP32 devices up to date, your job just became a lot easier.
Hope you enjoyed the article. Make sure to check out the associated YouTube video to see the three OTA demonstrations in action.
Parts List
Here are some components that you might need to complete the experiments in this article. Please note that some of these links may be affiliate links, and the DroneBot Workshop may receive a commission on your purchases. This does not increase your cost and is a way to support this ad-free website.
XIAO ESP32-S3 Seeed Studio Mouser
XIAO Expansion Board Seeed Studio Mouser
Resources
Code for this Article – All of the code used in the article, in a handy ZIP file. Includes Server files
PDF Version – The article in a PDF format
OTA Updates – Espressif’s guide to Over-the-Air updates.






Great article! Thanks Bill.