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 &#8212; 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 size_t image_size() const

Expected image size passed to begin() (0 if unknown / streaming).

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

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

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

set_log_level

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

get_log_level

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

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

Public Members

std::string project_name

Project name (CMake project / PROJECT_NAME).

std::string version

Application version (git describe / PROJECT_VER).

std::string date

Compile date.

std::string time

Compile time.

std::string idf_version

ESP-IDF version the image was built with.

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

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.

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