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.
The frame codec itself lives in the reusable Stream Frame APIs
component (detail/ota_stream_protocol.hpp re-exports it and layers the OTA
message types on top); to run OTA alongside other protocols (crash-dump, CAN,
…) on one stream, register it as a module with the
Dispatcher APIs — the ota example does exactly this (OTA is
module id 0 by default; OtaService::Config::module moves an instance to
any id, and the stock OTA console / espp_ota CLI still find it there
through discovery — see Building Custom Modules & Protocols).
Command line: build → OTA
The ota component ships an idf.py extension (idf_ext.py) and a
pure-Python host tool (components/ota/python/espp_ota), so any project using
it gets an idf.py ota-usb action that builds and OTA-flashes over USB in one
step — the OTA counterpart to idf.py flash, with options:
pip install pyusb # once (needs a libusb backend)
idf.py ota-usb # builds the app, then OTAs it over USB (+ marks it valid)
idf.py ota-usb --no-verify # ... without the reconnect + mark-valid
idf.py ota-usb --status # query the rollback state instead of flashing
idf.py ota-usb --mark-valid # confirm the running image / --rollback to reject it
idf.py ota-usb --pid 0x1234 --serial ABC123
The action needs ESP-IDF 6.0 or later (idf.py of 5.x loads neither component
extensions nor entry points). ESP-IDF 6.0 and 6.0.1 load every participating
component’s extension; from 6.0.2 on idf.py loads a component’s extension only
from trusted sources (ESP-IDF, the project’s components,
EXTRA_COMPONENT_DIRS, espressif/ registry components), so there a
registry install of espp/ota needs IDF_EXTENSION_ALLOW_UNTRUSTED=1, or
the espp wheel installed in the IDF Python environment (its idf_extension
entry point is loaded without a trust check). A plain ota-usb CMake target
remains as an option-less fallback, and is all that ESP-IDF 5.x gets.
The tool draws a live progress bar (percent, size, transfer speed, ETA) and
colorizes its output. Because idf.py captures the target’s output, the bar is
drawn straight to the controlling terminal so it still animates in place:
For full control (a specific serial, chunk size, discovery probe) run it directly
with python -m espp_ota flash build/<app>.bin — see
components/ota/python/README.md.
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)
// --- 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 will ROLL BACK to the previous // image on the next reset unless it is confirmed. This example demonstrates // HOST-DRIVEN confirmation: it deliberately does NOT mark itself valid here. // Instead it stays pending and lets the host confirm it (MARK_VALID over the // OTA protocol) once the host has verified the device is healthy — a broken // build could otherwise self-validate right before failing. The ota-console web // app / `espp-ota` CLI do this after reconnecting. // // (If your own product prefers device self-validation, run your health checks // here and call ota.mark_app_valid() / ota.mark_app_invalid_and_rollback().) if (ota.is_pending_verify()) { logger.warn("This image is PENDING VERIFY (first boot after an OTA update). Waiting for the " "host to confirm it (MARK_VALID); it rolls back on the next reset if not."); } // --- Core dump engine -------------------------------------------------------- // A panic core-dumps to the `coredump` partition and is reported here on the // next boot; the coredump console downloads / erases it over the service // below (every espp USB example serves the same standard set: system, // monitor, OTA, core dump). espp::CoreDump core_dump({.log_level = espp::Logger::Verbosity::INFO}); const std::string crash_report = core_dump.format_report(); if (crash_report.empty()) logger.info("Clean boot history (reset reason: {})", espp::CoreDump::reset_reason_name(espp::CoreDump::reset_reason())); else logger.error("Previous abnormal reset:\n{}", crash_report); // --- 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. The protocol itself (BEGIN/DATA/FINISH/ABORT, session ownership, // rollback status/confirmation, the post-update restart) is implemented by // espp::OtaService; this example only wires it to a transport. 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; // Add a CDC-ACM function so the SAME native USB cable also carries the log // console. `route_console = true` makes UsbDevice redirect the ESP console // (stdout: printf / ESP_LOG / espp::Logger) to this CDC interface at the end of // initialize(), teeing to the UART0 console so `idf.py monitor` still works. So // one native USB cable carries both the OTA vendor stream and the logs -- no // manual VFS plumbing in the app. CDC uses 1 interrupt IN + 1 bulk IN + 1 bulk // OUT; with the vendor function's bulk IN + OUT that is 3 IN / 2 OUT endpoints, // within the ESP32-S3 budget. espp::UsbDevice::CdcFunction cdc; cdc.interface_name = "espp OTA console"; cdc.route_console = true; // redirect the console to this CDC interface (tee to UART0) usb_cfg.cdc = cdc; espp::UsbDevice usb(usb_cfg); // Every device->host write on the vendor interface (OTA replies, the // streamed monitor events, discovery) comes from more than one task, so it // goes through one mutex; write_vendor is all-or-nothing per call, so a // frame is never truncated or interleaved. std::mutex vendor_tx_mutex; auto vendor_send = [&](std::span<const uint8_t> frame) { std::lock_guard<std::mutex> lock(vendor_tx_mutex); usb.write_vendor(frame); }; // The OTA service on this transport: replies go back over the vendor // interface. One OtaService per byte stream -- it only ever appends to / // finishes / aborts a session IT began, so a USB DATA frame can never touch // the HTTP-started session below (and vice versa). espp::OtaService ota_service(ota, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); // The other standard services on the same stream: system info + reboot / // bootloader (module 7), heap + task monitor (module 8), core dump (module 4). auto reboot_request = [&](espp::SystemService::RebootKind kind) { logger.warn("Host requested a {}; allowing it", kind == espp::SystemService::RebootKind::Bootloader ? "reboot into the bootloader" : "reboot"); return true; }; espp::SystemService system_service({.send = vendor_send, .on_reboot_request = reboot_request, .log_level = espp::Logger::Verbosity::INFO}); espp::MonitorService monitor_service( {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); espp::CoreDumpService coredump_service( core_dump, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); // RX bytes arrive in the TinyUSB task context, where nothing may block -- // and an OTA BEGIN erases a partition (seconds). DispatcherWorker owns the // bounded receive queue + worker task that feeds a Dispatcher, routing each // frame to its module (OTA on module 0; other protocols could register // alongside on the same stream) on its own task. On an RX overflow it resets // the parser and lets the OTA service abort the transfer + tell the host. espp::DispatcherWorker usb_link({.send = vendor_send, .on_overflow = [&]() { ota_service.on_rx_overflow(); }, .task_config = {.name = "ota_usb", .stack_size_bytes = 8192}}); usb_link.register_module(ota_service); // module 0 + its discovery metadata usb_link.register_module(system_service); usb_link.register_module(monitor_service); usb_link.register_module(coredump_service); usb_link.serve_discovery(usb_cfg.product); // so the browser Device Hub can find them usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) { usb_link.push(data); }); std::error_code usb_ec; if (!usb.initialize(usb_ec)) logger.error("Failed to initialize USB device: {}", usb_ec.message()); // On success the console is now on USB-CDC (cdc.route_console above), teed to // UART0 -- this and later logs travel over the native USB cable and UART0. // --- 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}; // Status + host-driven rollback endpoints backing the upload page's status // card (running firmware, pending-verify state, mark-valid / rollback). const httpd_uri_t status_uri = { .uri = "/status", .method = HTTP_GET, .handler = ota_status_handler, .user_ctx = &ota}; const httpd_uri_t mark_valid_uri = {.uri = "/mark-valid", .method = HTTP_POST, .handler = ota_mark_valid_handler, .user_ctx = &ota}; const httpd_uri_t rollback_uri = { .uri = "/rollback", .method = HTTP_POST, .handler = ota_rollback_handler, .user_ctx = &ota}; httpd_register_uri_handler(http_server, &get_uri); httpd_register_uri_handler(http_server, &post_uri); httpd_register_uri_handler(http_server, &status_uri); httpd_register_uri_handler(http_server, &mark_valid_uri); httpd_register_uri_handler(http_server, &rollback_uri); logger.info("HTTP OTA server ready: GET /ota (upload page + status), POST /ota (raw image), " "GET /status, POST /mark-valid, POST /rollback"); 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
Header File
Classes
-
class OtaService : public espp::BaseComponent
Transport-agnostic service exposing an espp::Ota engine over any framed byte stream (dispatcher module 0 by default; see Config::module).
See detail/ota_stream_protocol.hpp for the wire protocol. Flow control is one request in flight: the host waits for OK / ERROR before sending the next DATA frame, so a well-behaved host never queues more than ~one frame.
**Restart after an update**: with `Config::auto_restart` (the default) a successful FINISH replies OK and then restarts the device after `Config::restart_delay` (from a detached thread, so the reply has left the transport). Set `auto_restart = false` to decide yourself: the `on_update_finished` callback fires (outside the lock) and the app calls `Ota::restart()` when convenient.
**Rollback is host-driven**: after an update the new image boots PENDING_VERIFY; the host confirms it with MARK_VALID (or rejects it with MARK_INVALID) after checking the device is healthy. This service never marks the running image valid on its own.
**Threading**: an internal mutex covers the parser, the session ownership flag and each engine call, and the `send` callback always runs after it is released (so a re-entrant transport cannot deadlock). Requests are handled one at a time, but the mutex is released between the frames of one feed() call and the OTA protocol is order-sensitive (BEGIN, DATA…, FINISH), so drive one instance from ONE context per byte stream — a Dispatcher / DispatcherWorker feeding it, or a single task calling feed(). Engine calls block (a BEGIN erases the target partition, which can take seconds), so that context should be a worker task rather than a transport’s receive callback (see espp::DispatcherWorker).
OtaService Example
// --- 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 will ROLL BACK to the previous // image on the next reset unless it is confirmed. This example demonstrates // HOST-DRIVEN confirmation: it deliberately does NOT mark itself valid here. // Instead it stays pending and lets the host confirm it (MARK_VALID over the // OTA protocol) once the host has verified the device is healthy — a broken // build could otherwise self-validate right before failing. The ota-console web // app / `espp-ota` CLI do this after reconnecting. // // (If your own product prefers device self-validation, run your health checks // here and call ota.mark_app_valid() / ota.mark_app_invalid_and_rollback().) if (ota.is_pending_verify()) { logger.warn("This image is PENDING VERIFY (first boot after an OTA update). Waiting for the " "host to confirm it (MARK_VALID); it rolls back on the next reset if not."); } // --- Core dump engine -------------------------------------------------------- // A panic core-dumps to the `coredump` partition and is reported here on the // next boot; the coredump console downloads / erases it over the service // below (every espp USB example serves the same standard set: system, // monitor, OTA, core dump). espp::CoreDump core_dump({.log_level = espp::Logger::Verbosity::INFO}); const std::string crash_report = core_dump.format_report(); if (crash_report.empty()) logger.info("Clean boot history (reset reason: {})", espp::CoreDump::reset_reason_name(espp::CoreDump::reset_reason())); else logger.error("Previous abnormal reset:\n{}", crash_report); // --- 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. The protocol itself (BEGIN/DATA/FINISH/ABORT, session ownership, // rollback status/confirmation, the post-update restart) is implemented by // espp::OtaService; this example only wires it to a transport. 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; // Add a CDC-ACM function so the SAME native USB cable also carries the log // console. `route_console = true` makes UsbDevice redirect the ESP console // (stdout: printf / ESP_LOG / espp::Logger) to this CDC interface at the end of // initialize(), teeing to the UART0 console so `idf.py monitor` still works. So // one native USB cable carries both the OTA vendor stream and the logs -- no // manual VFS plumbing in the app. CDC uses 1 interrupt IN + 1 bulk IN + 1 bulk // OUT; with the vendor function's bulk IN + OUT that is 3 IN / 2 OUT endpoints, // within the ESP32-S3 budget. espp::UsbDevice::CdcFunction cdc; cdc.interface_name = "espp OTA console"; cdc.route_console = true; // redirect the console to this CDC interface (tee to UART0) usb_cfg.cdc = cdc; espp::UsbDevice usb(usb_cfg); // Every device->host write on the vendor interface (OTA replies, the // streamed monitor events, discovery) comes from more than one task, so it // goes through one mutex; write_vendor is all-or-nothing per call, so a // frame is never truncated or interleaved. std::mutex vendor_tx_mutex; auto vendor_send = [&](std::span<const uint8_t> frame) { std::lock_guard<std::mutex> lock(vendor_tx_mutex); usb.write_vendor(frame); }; // The OTA service on this transport: replies go back over the vendor // interface. One OtaService per byte stream -- it only ever appends to / // finishes / aborts a session IT began, so a USB DATA frame can never touch // the HTTP-started session below (and vice versa). espp::OtaService ota_service(ota, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); // The other standard services on the same stream: system info + reboot / // bootloader (module 7), heap + task monitor (module 8), core dump (module 4). auto reboot_request = [&](espp::SystemService::RebootKind kind) { logger.warn("Host requested a {}; allowing it", kind == espp::SystemService::RebootKind::Bootloader ? "reboot into the bootloader" : "reboot"); return true; }; espp::SystemService system_service({.send = vendor_send, .on_reboot_request = reboot_request, .log_level = espp::Logger::Verbosity::INFO}); espp::MonitorService monitor_service( {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); espp::CoreDumpService coredump_service( core_dump, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); // RX bytes arrive in the TinyUSB task context, where nothing may block -- // and an OTA BEGIN erases a partition (seconds). DispatcherWorker owns the // bounded receive queue + worker task that feeds a Dispatcher, routing each // frame to its module (OTA on module 0; other protocols could register // alongside on the same stream) on its own task. On an RX overflow it resets // the parser and lets the OTA service abort the transfer + tell the host. espp::DispatcherWorker usb_link({.send = vendor_send, .on_overflow = [&]() { ota_service.on_rx_overflow(); }, .task_config = {.name = "ota_usb", .stack_size_bytes = 8192}}); usb_link.register_module(ota_service); // module 0 + its discovery metadata usb_link.register_module(system_service); usb_link.register_module(monitor_service); usb_link.register_module(coredump_service); usb_link.serve_discovery(usb_cfg.product); // so the browser Device Hub can find them usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) { usb_link.push(data); }); std::error_code usb_ec; if (!usb.initialize(usb_ec)) logger.error("Failed to initialize USB device: {}", usb_ec.message()); // On success the console is now on USB-CDC (cdc.route_console above), teed to // UART0 -- this and later logs travel over the native USB cable and UART0. // --- 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}; // Status + host-driven rollback endpoints backing the upload page's status // card (running firmware, pending-verify state, mark-valid / rollback). const httpd_uri_t status_uri = { .uri = "/status", .method = HTTP_GET, .handler = ota_status_handler, .user_ctx = &ota}; const httpd_uri_t mark_valid_uri = {.uri = "/mark-valid", .method = HTTP_POST, .handler = ota_mark_valid_handler, .user_ctx = &ota}; const httpd_uri_t rollback_uri = { .uri = "/rollback", .method = HTTP_POST, .handler = ota_rollback_handler, .user_ctx = &ota}; httpd_register_uri_handler(http_server, &get_uri); httpd_register_uri_handler(http_server, &post_uri); httpd_register_uri_handler(http_server, &status_uri); httpd_register_uri_handler(http_server, &mark_valid_uri); httpd_register_uri_handler(http_server, &rollback_uri); logger.info("HTTP OTA server ready: GET /ota (upload page + status), POST /ota (raw image), " "GET /status, POST /mark-valid, POST /rollback"); 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 Stream = espp::stream_frame::StreamParser
Frame-stream parser type (from the shared stream_frame codec).
-
using MessageType = espp::detail::ota_stream::MessageType
The OTA wire protocol (message types, frame builders).
-
using send_fn = std::function<void(std::span<const uint8_t> frame)>
Transmits one encoded reply frame to the host.
-
using finished_fn = std::function<void()>
Notified (outside the lock) after a successful FINISH has been acknowledged.
Public Functions
-
inline explicit OtaService(Ota &ota, const Config &config)
Construct the service.
- Parameters:
ota – The OTA engine to drive (may be shared between several service instances / transports; must outlive the service).
config – Configuration parameters (the reply `send` function, …).
-
inline uint8_t module_id() const
The dispatcher module id this service answers on (Config::module; kModule by default, which the OTA console / CLI expect).
-
inline Dispatcher::ModuleInfo module_info() const
Discovery metadata for registering this service on a Dispatcher.
-
inline bool owns_session() const
Whether an update session begun through THIS service is in progress.
-
inline void handle(const espp::stream_frame::Frame &frame)
Dispatcher entry point: handle one routed frame.
Frames for other modules and reply-flagged frames (e.g. an echo) are ignored, so this can be registered directly: `dispatcher.register_module(service)`.
-
inline void feed(std::span<const uint8_t> data)
Feed received transport bytes (standalone use, without a Dispatcher).
Runs the internal incremental frame parser and handles every complete request frame of this module, one reply at a time.
-
inline bool handle_frame(uint8_t type, std::span<const uint8_t> payload)
Handle one already-parsed request frame.
Note
The reply `send` callback is invoked after the internal mutex has been released.
- Parameters:
type – The frame type byte (MessageType).
payload – The frame payload bytes.
- Returns:
true if the type belongs to the OTA protocol (a reply was produced and sent), false if it was ignored.
-
inline bool on_rx_overflow()
Tell the service that received bytes were dropped (transport RX overflow). An image being transferred through this service is now unusable: its session is aborted and the host is told (ERROR) to restart the update. Also resets the standalone parser.
- Returns:
true if an update session owned by this service was aborted (an ERROR reply was sent); false if no transfer was in progress here (nothing is sent — the dropped bytes belonged to another module, which may want to reply on its own).
-
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
Public Static Attributes
-
static constexpr uint8_t kModule = espp::detail::ota_stream::kModule
Default dispatcher module id of the OTA protocol (0). Only a routing key: Config::module serves on any id, and the hosted OTA console and the `espp_ota` CLI find it there through discovery (by kProtocol).
-
static constexpr const char *kProtocol = "espp.ota"
Stable protocol identifier + version advertised through discovery (Dispatcher::ModuleInfo::protocol); hosts locate the OTA module by this rather than by its (configurable) module id.
-
struct Config
Configuration for the OtaService.
Public Members
-
uint8_t module = {kModule}
Dispatcher module id this instance answers on (and stamps on its replies). The module id is purely a routing key: the stock OTA console / `espp_ota` CLI find whichever id is chosen through discovery (by kProtocol), so any id 0x00..0xEF is fine (0xF0..0xFF are reserved).
-
bool auto_restart = {true}
Restart the device after a successful FINISH (after the OK reply).
-
std::chrono::milliseconds restart_delay = {750}
Delay between the OK reply and the restart, so the reply reaches the host.
-
finished_fn on_update_finished = {nullptr}
Called after a successful FINISH was acknowledged (before the restart, if any). With `auto_restart = false` this is where the app schedules its own `Ota::restart()`.
-
uint8_t module = {kModule}
-
using Stream = espp::stream_frame::StreamParser