OTA (Over-the-Air Firmware Update)
The Ota class is a transport-agnostic OTA firmware update engine wrapping
ESP-IDF’s esp_ota_ops: a single mutex-serialized update session
(begin() -> write() … -> finish() / abort()) with all
failures reported via std::error_code. It performs no I/O itself — feed it
image bytes from any transport and it streams them into the next OTA app
partition. The first chunk is validated against the ESP image magic byte
(0xE9) and the incoming application descriptor (project name, version, build
date) is extracted, logged and exposed; finish() runs the full image
validation (including the appended SHA-256) and sets the boot partition, while
the restart is a separate explicit restart() call.
Rollback helpers (is_pending_verify(), mark_app_valid(),
mark_app_invalid_and_rollback()) integrate with the bootloader’s app
rollback support (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE): an app booted
pending-verify must call mark_app_valid() after its own health checks or
the bootloader rolls back to the previous image on the next reset.
For OTA over a raw byte stream (such as the
usb_device vendor / WebUSB interface), the header
detail/ota_stream_protocol.hpp provides a host-testable framed protocol —
CRC-32-verified little-endian frames (BEGIN / DATA / FINISH /
ABORT and OK / ERROR / PROGRESS replies) with an incremental,
resynchronizing parser and a bounded 4096-byte maximum payload. The hosted
espp OTA Console web
app speaks this protocol over WebUSB directly from a Chromium browser.
API Reference
Header File
Classes
-
class Ota : public espp::BaseComponent
Transport-agnostic OTA (over-the-air) firmware update engine.
Wraps ESP-IDF’s `esp_ota_ops` in an idiomatic espp API: no exceptions, all failures reported via `std::error_code`, and a single mutex-serialized update session (`begin()` -> `write()`… -> `finish()` / `abort()`). The component performs no I/O of its own — feed it image bytes from ANY transport (USB vendor / WebUSB stream, HTTP request body, TCP socket, UART, SD card, …) and it streams them into the next OTA app partition.
On the first chunk of `write()` the image is sanity-checked (the ESP image magic byte 0xE9) and the embedded application descriptor (`esp_app_desc_t`: project name, version, build date) is extracted, logged and exposed via `incoming_app_description()`; with `Config::reject_same_version` set, an image whose version matches the running app is rejected. `finish()` runs the full image validation (including the appended SHA-256) via `esp_ota_end()` and sets the boot partition, but does NOT restart — call `restart()` when the application is ready to reboot into the new image.
Rollback
The rollback helpers (`is_pending_verify()`, `mark_app_valid()`, `mark_app_invalid_and_rollback()`) require the bootloader rollback support to be compiled in (`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`). With rollback enabled, a freshly-updated app boots in the `ESP_OTA_IMG_PENDING_VERIFY` state and MUST call `mark_app_valid()` after its own health checks pass — otherwise the bootloader rolls back to the previous image on the next reset. Call `mark_app_invalid_and_rollback()` to actively reject the new image and reboot into the previous one.
OTA Example (USB / WebUSB + WiFi / Ethernet HTTP)
namespace proto = espp::detail::ota_stream; // --- The OTA engine (transport-agnostic) ----------------------------------- espp::Ota ota({.reject_same_version = false, .progress_callback = [&logger](size_t written, size_t total) { // log every ~64 KiB so a big image doesn't spam the log if (total > 0 && (written % (64 * 1024)) < 4096) logger.info("OTA progress: {} / {} bytes", written, total); }, .log_level = espp::Logger::Verbosity::INFO}); const auto running = ota.running_app_description(); logger.info("Running '{}' version '{}' (built {} {}) from partition '{}' ({} bytes)", running.project_name, running.version, running.date, running.time, ota.running_partition_label(), ota.running_partition_size()); logger.info("Next update will target partition '{}' ({} bytes)", ota.update_partition_label(), ota.update_partition_size()); // --- Rollback handling ------------------------------------------------------ // With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE, an app booted right after an // OTA update is in the PENDING_VERIFY state: it must prove it is healthy and // mark itself valid, or the bootloader ROLLS BACK to the previous image on // the next reset. Run the application's self-checks here; this example's // trivial check is that we made it this far with some free heap. if (ota.is_pending_verify()) { logger.warn("This image is PENDING VERIFY (first boot after an OTA update)"); const bool self_check_passed = esp_get_free_heap_size() > 10 * 1024; std::error_code ec; if (self_check_passed && ota.mark_app_valid(ec)) { logger.info("Self-check passed -> image marked VALID; rollback cancelled"); } else { logger.error("Self-check failed ({}) -> rolling back to the previous image", ec.message()); ota.mark_app_invalid_and_rollback(ec); // reboots into the old image } } // --- Transport 1: USB vendor / WebUSB (espp::UsbDevice) -------------------- // The vendor interface carries the framed OTA stream protocol (see // detail/ota_stream_protocol.hpp); the hosted web app // https://esp-cpp.github.io/espp/apps/ota_console.html speaks it in the // browser. RX bytes arrive in the TinyUSB task context, so they are queued // and dispatched from a worker task below (esp_ota_begin's flash erase can // take seconds and must not block the USB stack). espp::UsbDevice::Config usb_cfg; usb_cfg.manufacturer = "espp"; usb_cfg.product = "espp OTA"; usb_cfg.log_level = espp::Logger::Verbosity::INFO; espp::UsbDevice::VendorFunction vendor; vendor.interface_name = "espp OTA (WebUSB)"; vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors vendor.landing_page_url = "esp-cpp.github.io/espp/apps/ota_console.html"; usb_cfg.vendor = vendor; espp::UsbDevice usb(usb_cfg); std::mutex usb_rx_mutex; std::condition_variable usb_rx_cv; std::deque<std::vector<uint8_t>> usb_rx_queue; size_t usb_rx_queued_bytes = 0; bool usb_rx_overflow = false; // The protocol is one-frame-in-flight (the host waits for OK/ERROR before // the next DATA), so a well-behaved host queues at most ~one frame while the // worker is busy. Cap the queue anyway: the worker can legitimately block // for seconds inside esp_ota_begin()/end() (flash erase / SHA validation), // and a misbehaving host that pipelines OUT transfers must not be able to // exhaust device RAM. 8 max-size frames of headroom is far more than the // protocol ever needs. static constexpr size_t kMaxQueuedRxBytes = 8 * espp::detail::ota_stream::kMaxFrameSize; usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) { // TinyUSB task context: just queue the bytes and wake the worker. { std::lock_guard<std::mutex> lock(usb_rx_mutex); if (usb_rx_queued_bytes + data.size() > kMaxQueuedRxBytes) { // Overflow: drop everything (partial frames are useless once bytes // are missing) and let the worker abort + resynchronize + reply. usb_rx_queue.clear(); usb_rx_queued_bytes = 0; usb_rx_overflow = true; } else { usb_rx_queue.emplace_back(data.begin(), data.end()); usb_rx_queued_bytes += data.size(); } } usb_rx_cv.notify_one(); }); std::error_code usb_ec; if (!usb.initialize(usb_ec)) logger.error("Failed to initialize USB device: {}", usb_ec.message()); proto::StreamParser parser; bool restart_pending = false; // The OTA engine serializes sessions across ALL transports, but that alone // is not enough here: without ownership tracking a USB DATA/FINISH/ABORT // could append to / activate / cancel a session that HTTP started. Set only // after a successful USB BEGIN; cleared on every terminal path (FINISH and // ABORT end the session in all outcomes, and a failed write() aborts it). // If the host unplugs mid-session the flag stays set, so a reconnecting // host's ABORT is still honored (BEGIN would correctly fail busy first). bool usb_owns_session = false; auto handle_usb_frame = [&](const proto::Frame &frame) { std::error_code ec; auto reply_error = [&](const std::error_code &err, const std::string &context) { usb.write_vendor( proto::make_error(static_cast<uint32_t>(err.value()), context + ": " + err.message())); }; switch (frame.type) { case proto::MessageType::Begin: { const auto image_size = proto::parse_u32_payload(frame); if (!image_size.has_value()) { reply_error(std::make_error_code(std::errc::invalid_argument), "malformed BEGIN"); break; } if (ota.begin(*image_size, ec)) { usb_owns_session = true; usb.write_vendor(proto::make_ok(0)); } else { // busy = another transport's session; ownership stays false reply_error(ec, "begin failed"); } break; } case proto::MessageType::Data: if (!usb_owns_session) { reply_error(std::make_error_code(std::errc::operation_not_permitted), "no USB-owned update session (send BEGIN first)"); break; } if (ota.write(frame.payload, ec)) { usb.write_vendor(proto::make_ok(static_cast<uint32_t>(ota.bytes_written()))); } else { usb_owns_session = false; // write() aborted the session on failure reply_error(ec, "write failed"); } break; case proto::MessageType::Finish: { if (!usb_owns_session) { reply_error(std::make_error_code(std::errc::operation_not_permitted), "no USB-owned update session (send BEGIN first)"); break; } const auto written = static_cast<uint32_t>(ota.bytes_written()); usb_owns_session = false; // finish() ends the session in all outcomes if (ota.finish(ec)) { usb.write_vendor(proto::make_ok(written)); restart_pending = true; // reply first; the worker restarts shortly } else { reply_error(ec, "finish (validate/activate) failed"); } break; } case proto::MessageType::Abort: { if (!usb_owns_session) { reply_error(std::make_error_code(std::errc::operation_not_permitted), "no USB-owned update session to abort"); break; } const auto written = static_cast<uint32_t>(ota.bytes_written()); usb_owns_session = false; // session over either way if (ota.abort(ec)) usb.write_vendor(proto::make_ok(written)); else reply_error(ec, "abort failed"); break; } default: reply_error(std::make_error_code(std::errc::not_supported), "unknown message type"); break; } }; espp::Task usb_task( {.callback = [&](std::mutex &, std::condition_variable &) -> bool { std::vector<std::vector<uint8_t>> chunks; bool overflowed = false; { std::unique_lock<std::mutex> lock(usb_rx_mutex); usb_rx_cv.wait_for(lock, 100ms, [&] { return !usb_rx_queue.empty() || usb_rx_overflow; }); chunks.assign(std::make_move_iterator(usb_rx_queue.begin()), std::make_move_iterator(usb_rx_queue.end())); usb_rx_queue.clear(); usb_rx_queued_bytes = 0; overflowed = usb_rx_overflow; usb_rx_overflow = false; } if (overflowed) { // Bytes were dropped: any in-flight frame/image is // unusable. Abort a USB-owned session, resync the // parser, and tell the host to start over. if (usb_owns_session) { std::error_code abort_ec; ota.abort(abort_ec); usb_owns_session = false; } parser.reset(); usb.write_vendor(proto::make_error( static_cast<uint32_t>(std::make_error_code(std::errc::no_buffer_space).value()), "RX overflow: frames dropped; transfer aborted -- wait for OK " "replies between frames and restart the update")); return false; // dropped chunks are gone; skip parse } for (const auto &chunk : chunks) for (const auto &frame : parser.feed(chunk)) handle_usb_frame(frame); if (restart_pending) { // give the final OK reply time to reach the host std::this_thread::sleep_for(750ms); ota.restart(); } return false; // don't stop the task }, .task_config = {.name = "ota_usb", .stack_size_bytes = 8192}}); usb_task.start(); // --- Transports 2 & 3: WiFi (or Ethernet) + HTTP push ----------------------- // NVS is required by the WiFi stack. esp_err_t nvs_err = nvs_flash_init(); if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); nvs_err = nvs_flash_init(); } ESP_ERROR_CHECK(nvs_err); espp::WifiSta wifi_sta({.ssid = CONFIG_ESP_WIFI_SSID, .password = CONFIG_ESP_WIFI_PASSWORD, .num_connect_retries = CONFIG_ESP_MAXIMUM_RETRY, .on_connected = nullptr, .on_disconnected = nullptr, .on_got_ip = [&logger](ip_event_got_ip_t *eventdata) { logger.info("got IP: {}.{}.{}.{}", IP2STR(&eventdata->ip_info.ip)); logger.info(" browser upload page: http://{}.{}.{}.{}/ota", IP2STR(&eventdata->ip_info.ip)); logger.info(" curl --data-binary @build/ota_example.bin " "http://{}.{}.{}.{}/ota", IP2STR(&eventdata->ip_info.ip)); }, .log_level = espp::Logger::Verbosity::WARN}); // The HTTP server binds to every netif, so this exact same code serves OTA // over the espp `ethernet` component as well -- to use Ethernet, simply // bring up its netif (see the ethernet example) instead of WifiSta above. httpd_handle_t http_server = nullptr; httpd_config_t http_cfg = HTTPD_DEFAULT_CONFIG(); http_cfg.stack_size = 8192; // OTA handler streams through a 4 KiB buffer if (httpd_start(&http_server, &http_cfg) == ESP_OK) { const httpd_uri_t get_uri = { .uri = "/ota", .method = HTTP_GET, .handler = ota_get_handler, .user_ctx = nullptr}; const httpd_uri_t post_uri = { .uri = "/ota", .method = HTTP_POST, .handler = ota_post_handler, .user_ctx = &ota}; httpd_register_uri_handler(http_server, &get_uri); httpd_register_uri_handler(http_server, &post_uri); logger.info("HTTP OTA server ready: GET /ota (upload page), POST /ota (raw image)"); if constexpr (sizeof(CONFIG_EXAMPLE_OTA_HTTP_TOKEN) <= 1) { logger.warn("POST /ota is UNAUTHENTICATED (demo default): any peer that can reach this " "device can install structurally-valid firmware. Set EXAMPLE_OTA_HTTP_TOKEN in " "menuconfig to require a bearer token, and enable secure boot / signed images " "for real deployments."); } } else { logger.error("Failed to start HTTP server"); }
Public Types
-
using progress_callback_fn = std::function<void(size_t written, size_t total)>
Progress callback, invoked (with the session mutex held, so keep it short) after every successful write().
- Param written:
Total bytes written to the update partition so far.
- Param total:
Total expected image size in bytes (0 if unknown / streaming).
Public Functions
-
inline explicit Ota(const Config &config)
Construct the OTA engine. Does not touch the flash until begin().
- Parameters:
config – Configuration parameters.
-
inline ~Ota()
Aborts any still-active update session.
-
inline bool begin(size_t image_size, std::error_code &ec)
Start an update session targeting the next OTA app partition (esp_ota_get_next_update_partition()).
- Parameters:
image_size – Expected image size in bytes, or 0 if unknown / streaming (OTA_SIZE_UNKNOWN — the WHOLE update partition is erased up front, which can take several seconds; with a known size only the required range is erased).
ec – [out] Set on failure: a session is already active (device_or_resource_busy), no OTA partition exists (no_such_device), the image is larger than the partition (no_space_on_device), or the flash erase failed (io_error).
- Returns:
true if the session started, false otherwise (ec is set).
-
inline bool write(std::span<const uint8_t> data, std::error_code &ec)
Stream image bytes into the update partition.
The first chunk(s) are additionally used to validate the ESP image magic byte (0xE9) and — once enough bytes have arrived — to extract and log the incoming `esp_app_desc_t` (see incoming_app_description()). On ANY failure the session is aborted (a partially-written image is useless), so after an error the caller may simply start over with begin().
- Parameters:
data – Image bytes (any chunk size; empty is a no-op).
ec – [out] Set on failure: no active session (operation_not_permitted), bad image magic (illegal_byte_sequence), same-version rejection (file_exists, when Config::reject_same_version is set), the chunk crossing a declared image size (file_too_large — only that range was erased by begin()), or a flash write failure (io_error).
- Returns:
true if the bytes were written, false otherwise (ec is set).
-
inline bool finish(std::error_code &ec)
Finish the update: validate the complete image (esp_ota_end() checks the image structure and its appended SHA-256, plus the signature when secure boot is enabled) and set it as the boot partition.
Does NOT restart the device — call restart() when ready, so the application controls the timing (e.g. after flushing a reply to the host). The session is over after this call, whether it succeeds or fails.
- Parameters:
ec – [out] Set on failure: no active session (operation_not_permitted), image validation failed (illegal_byte_sequence), or setting the boot partition failed (io_error).
- Returns:
true if the new image is validated and set to boot, false otherwise.
-
inline bool abort(std::error_code &ec)
Abort the active update session (esp_ota_abort()) and reset the session state. Idempotent: succeeds as a no-op if no session is active.
- Parameters:
ec – [out] Set on failure (io_error if esp_ota_abort() fails).
- Returns:
true on success (or no-op), false otherwise (ec is set).
-
inline void restart()
Restart the device (esp_restart()); does not return. Call after a successful finish() to boot the new image.
-
inline bool is_pending_verify() const
Whether the RUNNING app is in the ESP_OTA_IMG_PENDING_VERIFY state, i.e. it was just installed by an OTA update and must call mark_app_valid() after its health checks, or the bootloader will roll back on the next reset.
Note
Only meaningful with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y; without it this always returns false.
-
inline bool mark_app_valid(std::error_code &ec)
Mark the running app valid and cancel a pending rollback (esp_ota_mark_app_valid_cancel_rollback()). An app booted in the pending-verify state must call this once its own health checks pass.
- Parameters:
ec – [out] Set on failure (io_error).
- Returns:
true on success, false otherwise (ec is set).
-
inline bool mark_app_invalid_and_rollback(std::error_code &ec)
Mark the running app invalid and reboot into the previous image (esp_ota_mark_app_invalid_rollback_and_reboot()). Does not return on success.
- Parameters:
ec – [out] Set on failure (io_error, e.g. no valid app to roll back to or rollback support not enabled).
- Returns:
false (only returns on failure; ec is set).
-
inline std::string running_partition_label() const
Label of the partition the current app is running from (”” if unknown).
-
inline size_t running_partition_size() const
Size in bytes of the partition the current app is running from (0 if unknown).
-
inline std::string boot_partition_label() const
Label of the currently-configured BOOT partition (”” if unknown). After a successful finish() this is the just-written partition.
-
inline std::string update_partition_label() const
Label of the partition the next update session will (or the active one does) target (”” if the partition table has no OTA slot).
-
inline size_t update_partition_size() const
Size in bytes of the update target partition (0 if none) — the maximum image size an update can carry.
-
inline AppDescription running_app_description() const
Description (project name / version / build date) of the RUNNING app.
-
inline std::optional<AppDescription> incoming_app_description() const
Description of the INCOMING image, available once enough of the image (the first 288 bytes) has been written in the current / latest session; std::nullopt before then or if the image carries no valid app descriptor.
-
inline bool session_active() const
Whether an update session is active (begin() succeeded and neither finish() nor abort() has ended it).
-
inline size_t bytes_written() const
Bytes written to the update partition in the current session.
-
inline const std::string &get_name() const
Get the name of the component
Note
This is the tag of the logger
- Returns:
A const reference to the name of the component
-
inline void set_log_tag(const std::string_view &tag)
Set the tag for the logger
- Parameters:
tag – The tag to use for the logger
-
inline espp::Logger::Verbosity get_log_level() const
Get the log level for the logger
See also
See also
- Returns:
The verbosity level of the logger
-
inline void set_log_level(espp::Logger::Verbosity level)
Set the log level for the logger
See also
See also
- Parameters:
level – The verbosity level to use for the logger
-
inline void set_log_verbosity(espp::Logger::Verbosity level)
Set the log verbosity for the logger
See also
See also
See also
Note
This is a convenience method that calls set_log_level
- Parameters:
level – The verbosity level to use for the logger
-
inline espp::Logger::Verbosity get_log_verbosity() const
Get the log verbosity for the logger
See also
See also
See also
Note
This is a convenience method that calls get_log_level
- Returns:
The verbosity level of the logger
-
inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)
Set the rate limit for the logger
See also
Note
Only calls to the logger that have _rate_limit suffix will be rate limited
- Parameters:
rate_limit – The rate limit to use for the logger
-
struct AppDescription
Application description extracted from an app image’s `esp_app_desc_t`.
-
struct Config
Configuration for the Ota engine.
Public Members
-
bool reject_same_version = {false}
Reject an incoming image whose `esp_app_desc_t` version string matches the running app’s version (checked on the first chunk of write()).
-
progress_callback_fn progress_callback = {nullptr}
Optional progress callback (see progress_callback_fn).
-
bool reject_same_version = {false}
-
using progress_callback_fn = std::function<void(size_t written, size_t total)>
Header File
Classes
-
class StreamParser
Incremental frame parser for the OTA stream protocol.
Feed arbitrary chunks of received bytes (USB bulk transfers, socket reads, single bytes, …) and it yields the complete, CRC-verified frames they contain. On a bad magic, an oversized length field (> kMaxPayloadSize) or a CRC mismatch it resynchronizes by discarding bytes until the next plausible frame start, so a corrupted stream recovers at the next intact frame.
Buffering is bounded: because the length field is capped, the parser never retains more than kMaxFrameSize bytes between feed() calls (plus at most the chunk currently being processed), so a remote-supplied length cannot cause memory exhaustion.
Public Functions
-
inline std::vector<Frame> feed(std::span<const uint8_t> data)
Feed received bytes to the parser.
- Parameters:
data – Any number of bytes (frames may be split or batched arbitrarily).
- Returns:
All complete, CRC-verified frames terminated by these bytes, in order.
-
inline void reset()
Discard all buffered bytes (e.g. on transport reconnect).
-
inline size_t buffered() const
Number of bytes currently buffered awaiting frame completion.
-
inline size_t dropped_bytes() const
Total bytes discarded so far while resynchronizing (diagnostics).
-
inline std::vector<Frame> feed(std::span<const uint8_t> data)