Async over-the-air firmware updater for ESP32 and ESP8266 — drag-drop in a browser, pull from a URL, sign with HMAC-SHA256, A/B-rollback if the new image goes bad. Apache-2.0 licensed, mobile-friendly, gzipped UI.
Author: VectiVolt · License: Apache-2.0 · Built and run on: ESP32-S3 · Also declared: ESP8266
library.json declares espressif32 and espressif8266, and src/ carries
real ESP8266 code paths (Updater.h, BearSSL HMAC). Only ESP32-S3 has been
built and run — see Limitations before you ship an ESP8266
image.
Two features are ESP32-only, because the hardware is: pull-from-URL
(/ota/pull answers 501 on ESP8266) and A/B rollback (ESP8266 has no
second app slot). Push upload, signing, auth, rate limiting and the UI are
written for both.
arduino-esp32 ships CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y. An OTA'd image therefore
boots as PENDING_VERIFY, and if nothing marks it valid the bootloader reverts to
the previous slot on the next reset — not immediately. The failure mode is nasty
because everything looks fine: the upload returns 200, the device reboots into the new
firmware, and the old firmware comes back a power cycle later.
So commit() must be called from a self-test that can actually pass on the device you
are shipping. The trap we hit on real hardware:
// WRONG on any device that serves its own portal.
// WL_CONNECTED is a STATION state and is never true for a SoftAP-only device,
// so commit() never runs and every OTA silently reverts.
if (uptime > 20000 && WiFi.status() == WL_CONNECTED) VectiOTA.commit();
// RIGHT — accept either radio role.
const bool sta = WiFi.status() == WL_CONNECTED;
const bool ap = (WiFi.getMode() & WIFI_MODE_AP) &&
WiFi.softAPIP() != IPAddress((uint32_t)0);
if (uptime > 20000 && (sta || ap)) VectiOTA.commit();Log the slot and its state at boot so this is diagnosable over a serial cable, without needing the network that may be the thing that is broken:
const esp_partition_t *run = esp_ota_get_running_partition();
esp_ota_img_states_t st = ESP_OTA_IMG_UNDEFINED;
if (run) esp_ota_get_state_partition(run, &st);
// -> "running slot=app0 state=PENDING_VERIFY" is the smoking gundemo/src/main.cpp does both.
Pull-from-URL is the expensive feature. It links HTTPClient, WiFiClientSecure and
the mbedTLS/x509 stack, and the linker keeps all of it whether or not you ever call it —
allowPullMode(false) is a runtime switch and reclaims nothing.
Measured on the four-library demo, board = esp32dev, same toolchain, one flag changed:
| Build | Flash | of a 1.3 MB app slot | RAM |
|---|---|---|---|
default (VECTIOTA_ENABLE_PULL=1) |
1,269,949 B | 96.9% | 63,576 B |
-DVECTIOTA_ENABLE_PULL=0 |
1,099,169 B | 83.9% | 61,484 B |
| saving | 170,780 B | 13 points | 2,092 B |
; platformio.ini — push-from-browser OTA only, no TLS stack
build_flags = -DVECTIOTA_ENABLE_PULL=0Turning it off removes the POST /ota/pull endpoint. Everything else — drag-and-drop
upload, signed images, A/B rollback, SSE progress, rate limiting, auth — is unaffected.
On a 4 MB module this is close to mandatory: the default build leaves under 41 KB of headroom in the stock partition table. If you do not push firmware to your fleet by URL, turn it off.
| 🖱 Drag-drop UI | Polished single-page updater with SVG progress ring, live byte/throughput counters, mode tabs |
| ☁ Pull from URL (ESP32) | Tell the device to fetch a firmware from any HTTP/HTTPS URL, redirects followed — perfect for fleet rollouts from a build pipeline |
| 🔐 Signed firmware (optional) | HMAC-SHA256 over the uploaded image, hashed incrementally as it streams, constant-time compare. Disabled by default; enable with one call |
| ↺ A/B rollback (ESP32) | If the new firmware never calls commit() within your timeout, the bootloader picks the previous slot on the next reset |
| 📁 Firmware + filesystem | Switch modes from the same UI to flash either the app partition or SPIFFS / LittleFS |
| 📡 Live progress via SSE | Every connected browser tab updates in real-time, not just the one that started the upload |
| ⏱ Rate limiting | Per-IP minimum gap (default 5 s) so a brute-force auth attempt can't burn the flash erase cycle counter |
| 🪪 HW ID + version | Free-form identity strings exposed on the page and in /ota/info JSON |
| 🔒 HTTP Basic + token auth | Pick OtaAuth::Basic for browser flows, OtaAuth::Token for headless CI |
| 🎨 Theme aware | Dark / light / auto; user choice persists; brand colour configurable |
| 📱 Mobile-first | 44 px touch targets, viewport-fit safe-area, glass-morphism panels |
| 🪶 Pre-gzipped UI | 71,475 B of HTML ships as a 25,822 B PROGMEM blob and goes out with Content-Encoding: gzip |
Three lines on top of an AsyncWebServer you already have:
VectiOTA.begin(&server, "admin", "vecti"); // mounts /ota + 6 routes
server.begin();
// …and VectiOTA.loop(); from loop()The whole sketch:
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <VectiOTA.h>
AsyncWebServer server(80);
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin("YOUR_SSID", "YOUR_PASS");
while (WiFi.status() != WL_CONNECTED) delay(250);
VectiOTA.setID(WiFi.macAddress());
VectiOTA.setFWVersion("1.0.0");
VectiOTA.begin(&server, "admin", "vecti");
server.begin();
VectiOTA.commit(); // mark this firmware as known-good
}
void loop() { VectiOTA.loop(); }Open http://<device-ip>/ota and drop a firmware.bin. Done.
void begin(AsyncWebServer *server,
const String &username = "",
const String &password = "");
void loop(); // call from loop() for pull-mode polling + rollback timingbegin() mounts every endpoint described in the table below. If
username is non-empty the library defaults to HTTP Basic auth using
those credentials; pass "" to disable auth.
void setID (const String &id); // shown in UI + /ota/info
void setFWVersion (const String &ver);
void setTitle (const String &title); // page title
void setBrandColor (const String &cssColor); // accent colourenum class OtaAuth { None, Basic, Token };
void setAuth(OtaAuth mode, const String &userOrToken, const String &password = "");
void clearAuth();OtaAuth::Basic→ standard browser-prompted Basic auth.OtaAuth::Token→ expects headerX-Vecti-Token: <token>on every request. Use this for CI flows where you don't want a browser dialog.
void setSigningKey(const String &hexKey); // empty = disable (default)If a key is set, each /ota/upload must include header
X-Vecti-Signature: <hex> where hex is the HMAC-SHA256 of the image
computed with the key. The digest is fed to the HMAC chunk by chunk as the
body streams in (nothing is buffered) and compared — constant-time, against
the decoded bytes so case doesn't matter — before Update.end() marks the
new slot bootable. A missing, malformed or wrong signature aborts the
updater and answers 400; the check never fails open. Recommended key
length: 32 bytes (64 hex chars).
This covers push uploads only. A pulled image carries no signature — pin a
CA for it with setPullCACert() instead.
void setRateLimitMs (uint32_t ms); // min gap per IP: upload, pull, rollback
void allowFirmwareUpdates (bool on); // default true
void allowFilesystemUpdates(bool on); // default true
void allowPullMode (bool on); // default true
void setPullCACert (const char *pemRootCa); // pin the TLS root for /ota/pull
void allowInsecurePullTls(bool on); // default false — see belowhttps:// pull URLs are refused unless you either pin a root CA or
explicitly opt into allowInsecurePullTls(true). WiFiClientSecure
verifies nothing until it is told what to trust, so an unpinned pull would
flash whatever answers the DNS query. The PEM string is not copied — pass a
literal or something else that outlives the device.
void setRollbackTimeoutMs(uint32_t ms); // 0 = disabled (default)
void commit(); // call from setup() after self-test
void rollback(); // revert to the previous slot + rebootThe watchdog only arms when the bootloader is actually waiting on a verdict
for the running image (slotState: "pending" in /ota/info). A
serially-flashed build, a single-app partition table and every ESP8266 are
left alone, so a failing self-test can't turn into a reset loop.
rollback() on a device with no other valid slot emits a
rollback-unavailable status event and does nothing; POST /ota/rollback
checks the same condition up front and answers 409 (or 501 on ESP8266)
instead of 200. Call order doesn't matter — setRollbackTimeoutMs() works
before or after begin().
Recommended flow:
void setup() {
…
VectiOTA.setRollbackTimeoutMs(30000); // 30 s grace period
VectiOTA.begin(&server, "admin", "pass");
if (runSelfTest()) VectiOTA.commit();
// else: do nothing → bootloader will revert on next reset
}using OtaStartCb = std::function<void(OtaMode mode)>;
using OtaProgressCb = std::function<void(size_t current, size_t total)>;
using OtaEndCb = std::function<void(bool success, const String &msg)>;
using OtaErrorCb = std::function<void(const String &reason)>;
using OtaRebootCb = std::function<bool()>; // return false to suppress reboot
void onStart (OtaStartCb cb);
void onProgress (OtaProgressCb cb);
void onEnd (OtaEndCb cb);
void onError (OtaErrorCb cb);
void onBeforeReboot(OtaRebootCb cb);Use onBeforeReboot to do a graceful shutdown (close TLS sockets, write
state to NVS, …) before the device restarts after a successful flash.
bool isUpdating() const;
size_t bytesWritten() const;
size_t totalBytes() const;
uint8_t progressPct() const;| Path | Method | Auth | Description |
|---|---|---|---|
/ota |
GET | yes | The drag-drop SPA |
/ota/info |
GET | yes | JSON snapshot of device + partition state |
/ota/upload |
POST | yes | Multipart firmware/filesystem upload (?mode=firmware|filesystem) |
/ota/pull |
POST | yes | {"url":"…","mode":"firmware"} — device fetches over HTTP/HTTPS (ESP32 only; 501 elsewhere) |
/ota/events |
SSE | yes | Live progress + status events. Basic auth only — an EventSource can't send X-Vecti-Token, so token mode closes this endpoint rather than leaving it open |
/ota/commit |
POST | yes | Mark current slot valid (cancels pending rollback) |
/ota/rollback |
POST | yes | Revert to previous slot and reboot. 409 rollback-unavailable when there is no other valid slot (serial-flashed image, single-app partition table); 501 on ESP8266 |
{
"hwId": "D0CF13721758",
"fwVersion": "1.0.0+demo",
"title": "VectiSuite OTA",
"brand": "#0fd08c",
"freeHeap": 250248,
"currentSlot": "app0",
"nextSlot": "app1",
"freeOta": 3342336,
"slotState": "valid",
"updating": false,
"allowFw": true,
"allowFs": true,
"allowPull": true
}Those are every key the handler emits. On ESP8266 the partition trio
(currentSlot, nextSlot, slotState) is absent — only freeOta is
reported, from ESP.getFreeSketchSpace().
event: progress
data: {"written":131072,"total":1115632,"mode":"firmware"}
event: status
data: complete
progress fires every ~32 KB; status fires for lifecycle transitions
(start:firmware, complete, rate-limited, pull-http-404, …).
curl -u admin:vecti -X POST http://device.local/ota/pull \
-H "Content-Type: application/json" \
-d '{"url":"https://builds.example.com/v1.2.3/firmware.bin","mode":"firmware"}'The device queues the URL and answers 202 Accepted immediately, then
downloads on the next loop() tick, flashes and reboots. Other answers:
409 a pull or upload is already in flight, 429 rate-limited, 413
body over 512 bytes, 400 unparseable or no url, 501 on ESP8266.
The download itself is synchronous inside loop() — a 1 MB image blocks
your sketch for the length of the transfer. Redirects are followed
(HTTPC_STRICT_FOLLOW_REDIRECTS), so release assets and pre-signed
object-store URLs work. A stalled server ends the transfer after 10 s of
silence and the whole download is capped at 5 minutes; either way the image
is abandoned, never flashed part-way.
The response must carry a Content-Length. A chunked or length-less
response is refused with a pull-no-length status event: without a declared
size there is no way to tell a finished body from a stalled one, and a pull
has no signature to catch a truncated image the way /ota/upload does.
HTTPS needs a trust decision from you:
VectiOTA.setPullCACert(ISRG_ROOT_X1_PEM); // pin, or …
VectiOTA.allowInsecurePullTls(true); // … explicitly accept any certWith neither set, https:// URLs are refused with a pull-tls-unpinned
status event. Certificate fingerprint pinning is not supported — ESP32's
WiFiClientSecure has no fingerprint API.
-
Generate a 32-byte key once and store it in your build secrets:
openssl rand -hex 32 # 64-char hex -
In firmware:
VectiOTA.setSigningKey("a9f1…"); // the hex string
-
In your CI, sign each release before upload:
SIG=$(openssl dgst -sha256 -mac HMAC -macopt hexkey:a9f1… -hex firmware.bin | awk '{print $2}') curl -u admin:vecti -X POST http://device/ota/upload?mode=firmware \ -H "X-Vecti-Signature: $SIG" \ -F update=@firmware.bin
Unsigned uploads — and uploads with a wrong signature — are rejected with
400 Bad Request, and the updater is aborted so nothing is left half-written.
The browser UI does not compute signatures, so turning signing on makes
/ota/upload a CI-only endpoint.
ESP32's bootloader supports two app slots: app0 and app1. VectiOTA
writes the new image into whichever slot is not running, then flags the
new slot as PENDING_VERIFY. On the next reset the bootloader runs the
new firmware once. If your sketch calls VectiOTA.commit() before the
rollback timeout expires, the slot is marked VALID and stays the active
slot. If it doesn't (because the firmware crashed, hung, or failed
self-test) the bootloader picks the previous VALID slot on the next
reset and you're back to a known-good state.
void setup() {
// … bring up Wi-Fi …
VectiOTA.setRollbackTimeoutMs(30000); // 30 s grace
VectiOTA.begin(&server, "admin", "pw");
// run smoke-tests: peripherals reachable, NTP synced, etc.
if (smokeTestPasses()) {
VectiOTA.commit();
} else {
VectiSerial.err("self-test failed — letting bootloader roll back");
}
}For a manual rollback (e.g. operator-triggered from the UI):
VectiOTA.rollback(); // marks current invalid + rebootsVectiOTA.begin(&server, "admin", "strong-password");The browser prompts on first visit; credentials stick for the session.
VectiOTA.setAuth(vecti::OtaAuth::Token, "long-random-token");curl -X POST http://device/ota/upload?mode=firmware \
-H "X-Vecti-Token: long-random-token" \
-F update=@firmware.binVectiOTA.begin(&server); // pass empty strings| Section | What you see |
|---|---|
| Header | Device title + theme toggle (◐) |
| Device card | HWID, FW version, current slot (app0/app1), next slot, free OTA bytes, free heap |
| Mode tabs | 🔧 Firmware · 📁 Filesystem · ☁ Pull URL |
| Drop zone | Drag a .bin (or .bin.gz) anywhere on the card, or click to browse |
| Progress ring | SVG arc fills with a brand-gradient as bytes upload; live %/MB/throughput |
| Log pane | Per-event log: file name + size, percent ticks, status messages, errors |
| Pull-URL tab | URL input + "Pull & flash" button (queues a fetch on the device) |
| Maintenance card | ✓ Commit current · ↺ Rollback (red, confirmation prompt) |
Light theme (the ◐ toggle in the header; the choice persists in
localStorage["vecti-theme"]):
Mobile (390 px wide):
| Symptom | Cause | Fix |
|---|---|---|
400 Bad Request on upload |
Signed-firmware mode is on but signature header missing/wrong | Set the header or temporarily clear the key |
429 + rate-limited status event |
Too many upload/pull/rollback attempts from the same IP in the last 5 s | setRateLimitMs(0) or wait |
begin-failed:...esp_partition_find_first |
OTA partition layout missing | Use a default_8MB.csv (or larger) partition CSV |
| Upload completes, device reboots, then reverts once | Self-test isn't calling commit() after success |
Add VectiOTA.commit() at the end of setup() |
pull-tls-unpinned status event |
https:// pull URL with no CA pinned |
setPullCACert(), or allowInsecurePullTls(true) if you accept the risk |
Upload answers 409 busy |
A previous upload or pull is still running | Wait, or check /ota/info → updating |
| UI loads but progress stays at 0% | The browser tab uploading is fine; this tab is observing via SSE and the device is busy | Just wait — progress will sync up |
IncompleteRead on weak Wi-Fi |
TCP retransmits failing | VectiOTA does not touch the radio. Add WiFi.setSleep(false) and WiFi.setTxPower(WIFI_POWER_19_5dBm) in your sketch, move the device closer, or use a directional antenna |
| Concern | Roll-your-own | VectiOTA |
|---|---|---|
| Drag-drop UI | Write & maintain HTML | Included, 25,822 B gz |
| Pull-from-URL | Bespoke HTTP client + state machine | One POST /ota/pull |
| Signature check | Hook into mbedtls manually | setSigningKey() |
| A/B rollback | Read esp_ota_* APIs by hand |
setRollbackTimeoutMs() + commit() |
| Rate limiting | Add yourself | Built-in per-IP table |
| Live multi-client progress | Build an SSE channel | Included on /ota/events |
| Theme + branding | Custom CSS | setTitle(), setBrandColor() |
ESP32Async/ESPAsyncWebServer @ ^3.11.0— the floor is set byAsyncURIMatcher::exact(), which is a web-server API, not a core one. Anything older fails to compile with'AsyncURIMatcher' has not been declared.ESP32Async/AsyncTCP @ ^3.4.0bblanchon/ArduinoJson @ ^7.4.0- arduino-esp32 core 2.0.17 or newer (the demo builds on 2.0.17, platform
espressif32 @ 6.13.0)
Read this before you put VectiOTA on something you can't walk up to.
| Limitation | Detail |
|---|---|
| Signing is symmetric | setSigningKey() is HMAC-SHA256. The key that verifies the image is compiled into the image, so anyone who can read your flash can forge a signature. It stops a stolen Wi-Fi password from pushing a binary; it does not stop someone holding the board. Asymmetric signing (Ed25519) is a wanted future change, not a shipped feature |
| Pulled images are unsigned | /ota/pull verifies nothing about the payload beyond Content-Length. Pin a CA with setPullCACert() and treat TLS as the whole trust story for that path |
Pull blocks loop() |
The download runs synchronously inside loop(). A 1 MB image stalls your sketch for the length of the transfer |
| Rate limiter is 8 slots | A fixed 8-entry per-IP table, not a DoS-resistant cache. It exists to protect the flash erase counter, not to survive an attack |
| Basic auth is plaintext | HTTP Basic and X-Vecti-Token both cross the wire in the clear. LAN-grade. Put the device behind TLS or a VPN if the network is not trusted |
| ESP8266 is unbuilt | The code paths exist; no ESP8266 board has been flashed with them |
| Network paths unverified on hardware | The ESP32-S3 build flashes, boots and registers its routes. Upload, pull and rollback have not been exercised over a real network — the board was verified over USB serial only |
| New project | No CI, no test suite, no users yet, and not in the Arduino Library Manager. Install from Git or PlatformIO's lib_deps URL form |
VectiOTA's own code is Apache-2.0. It links ESPAsyncWebServer and AsyncTCP, both LGPL-3.0. There is no dynamic linking on an MCU, so LGPL §4's relink obligations attach to the binary you ship. Do not read "Apache-2.0" as "no copyleft obligations" — plan for the LGPL terms on the async stack. Every ESP32 async-web library in this space inherits exactly the same dependency, so this is a property of the ecosystem, not of this library.
Apache-2.0 — see LICENSE. See Limitations for the LGPL-3.0 obligations inherited from ESPAsyncWebServer / AsyncTCP.
Author: VectiVolt · Email: team@vectivolt.com · (c) 2026 — Apache-2.0


