Skip to content
vectivoltPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

Repository files navigation

VectiOTA

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.

VectiOTA 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.


⚠️ commit() and the rollback trap

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 gun

demo/src/main.cpp does both.

⚖️ Flash cost, and how to cut it

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=0

Turning 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.

Features

🖱 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

Quick start

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.


API reference

Lifecycle

void begin(AsyncWebServer *server,
           const String &username = "",
           const String &password = "");
void loop();          // call from loop() for pull-mode polling + rollback timing

begin() 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.

Identity & branding

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 colour

Authentication

enum 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 header X-Vecti-Token: <token> on every request. Use this for CI flows where you don't want a browser dialog.

Signed firmware (recommended for production)

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.

Policy

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 below

https:// 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.

Rollback

void setRollbackTimeoutMs(uint32_t ms);   // 0 = disabled (default)
void commit();                            // call from setup() after self-test
void rollback();                          // revert to the previous slot + reboot

The 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
}

Callbacks

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.

Status accessors

bool    isUpdating()    const;
size_t  bytesWritten()  const;
size_t  totalBytes()    const;
uint8_t progressPct()   const;

HTTP endpoints

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

/ota/info payload

{
  "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().

/ota/events event stream

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, …).


Pull-from-URL flow

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 cert

With 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.


Signed-firmware workflow

  1. Generate a 32-byte key once and store it in your build secrets:

    openssl rand -hex 32   # 64-char hex
  2. In firmware:

    VectiOTA.setSigningKey("a9f1…");      // the hex string
  3. 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.


Rollback explained

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 + reboots

Authentication patterns

Browser → HTTP Basic

VectiOTA.begin(&server, "admin", "strong-password");

The browser prompts on first visit; credentials stick for the session.

CI / scripts → token header

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.bin

Local-only / behind VPN → no auth

VectiOTA.begin(&server);              // pass empty strings

UI walkthrough

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"]):

VectiOTA updater, light theme

Mobile (390 px wide):

VectiOTA mobile


Troubleshooting

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

Comparison with build-it-yourself

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()

Dependencies

  • ESP32Async/ESPAsyncWebServer @ ^3.11.0 — the floor is set by AsyncURIMatcher::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.0
  • bblanchon/ArduinoJson @ ^7.4.0
  • arduino-esp32 core 2.0.17 or newer (the demo builds on 2.0.17, platform espressif32 @ 6.13.0)

Limitations

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

License position — honest version

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.


License

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages