Getting Started

Samfrevolt OTA — Client Library

A self-contained firmware update library for ESP32. Fetches the manifest, verifies SHA-256, streams the binary to the inactive OTA partition, and reboots — in a single function call. Works over Wi-Fi, Ethernet, GSM/LTE, or any network transport.

What the library does internally

When you call samfrevolt_ota_check_and_update() these steps execute in order:

  1. Builds the manifest URL — project ID, device ID, version, channel, hw_id, hw_rev
  2. Opens HTTPS to the server (TLS with ISRG Root X1; plain HTTP if base_url starts with http://)
  3. Parses the JSON manifest
  4. If device_locked: true → logs the lock reason, returns ESP_ERR_NOT_ALLOWED
  5. If update_available: false → returns ESP_OK immediately, nothing downloaded
  6. Opens the inactive OTA partition (the device keeps running from the current one)
  7. Streams the firmware binary in chunks, computing SHA-256 over every received byte simultaneously
  8. Compares the computed digest against the manifest sha256 field — aborts + discards if mismatch
  9. Calls esp_ota_set_boot_partition() to arm the new image for next boot
  10. Calls esp_restart()the function never returns on a successful update
C — complete app_main.c
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"
#include "string.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "samfrevolt_ota.h"

#define FIRMWARE_VERSION  "1.0.0"
#define OTA_PROJECT_ID    "your-project-id"      // from dashboard
#define OTA_API_KEY       "your-device-api-key"  // from dashboard Settings tab

static const char *TAG = "app";

void app_main(void)
{
    // ── 1. NVS init (required before WiFi and OTA) ─────────────────────
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
        err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);

    // ── 2. Connect to Wi-Fi ────────────────────────────────────────────
    //    Built-in helper: initialises netif + event loop, waits for IP.
    ESP_ERROR_CHECK(samfrevolt_wifi_connect("SSID", "PASSWORD", 15000));

    // ── 3. OTA config ──────────────────────────────────────────────────
    samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
    ota.project_id      = OTA_PROJECT_ID;
    ota.api_key         = OTA_API_KEY;
    ota.current_version = FIRMWARE_VERSION;
    // ota.channel      = "beta";   // "stable" | "beta" | "dev"
    // ota.hw_rev       = "rev-b"; // leave "" to match all revisions
    // ota.hw_id        = NULL;    // NULL = auto from eFuse MAC (recommended)
    // ota.timeout_ms   = 30000;   // increase for slow LTE connections

    // ── 4. Report result of previous update (run on next boot) ─────────
    //    Reads the version stored before the last update.
    //    If it differs, an OTA happened — report success to the server.
    nvs_handle_t nvs;
    if (nvs_open("ota", NVS_READWRITE, &nvs) == ESP_OK) {
        char prev[32] = {0};
        size_t sz = sizeof(prev);
        if (nvs_get_str(nvs, "prev_ver", prev, &sz) == ESP_OK
                && prev[0] && strcmp(prev, FIRMWARE_VERSION) != 0) {
            ESP_LOGI(TAG, "Reporting update %s → %s", prev, FIRMWARE_VERSION);
            samfrevolt_ota_report(&ota, prev, FIRMWARE_VERSION, true, NULL);
        }
        nvs_set_str(nvs, "prev_ver", FIRMWARE_VERSION);
        nvs_commit(nvs);
        nvs_close(nvs);
    }

    // ── 5. Check for update ─────────────────────────────────────────────
    //    If a newer version is available: downloads, verifies SHA-256,
    //    flashes inactive partition, and calls esp_restart().
    //    This call only returns when already up to date or on error.
    err = samfrevolt_ota_check_and_update(&ota);

    switch (err) {
        case ESP_OK:
            ESP_LOGI(TAG, "Firmware %s is current", FIRMWARE_VERSION);
            break;
        case ESP_ERR_NOT_ALLOWED:
            ESP_LOGE(TAG, "Device locked — contact project admin to reset");
            break;
        case ESP_ERR_INVALID_CRC:
            ESP_LOGE(TAG, "SHA-256 mismatch — download rejected, device safe");
            break;
        default:
            ESP_LOGW(TAG, "OTA check failed: %s", esp_err_to_name(err));
            break;
    }

    // ── Application runs here ───────────────────────────────────────────
    while (true) vTaskDelay(pdMS_TO_TICKS(1000));
}
C — W5500 SPI Ethernet PoE + OTA (esp-idf-ethernet-w5500/)
#include "nvs_flash.h"
#include "samfrevolt_ota.h"
// eth_connect() is defined in main.c — see the full example in the repo

// ── W5500 SPI wiring — adjust to match your PCB ──────────────────
#define ETH_SPI_MISO    19
#define ETH_SPI_MOSI    23
#define ETH_SPI_CLK     18
#define ETH_SPI_CS       5
#define ETH_INT_GPIO    26   // required — interrupt line
#define ETH_RST_GPIO    25   // use -1 if not wired

#define OTA_PROJECT_ID   "your-project-id"
#define OTA_API_KEY      "your-device-api-key"
#define FIRMWARE_VERSION "1.0.0"

void app_main(void)
{
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
        err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);

    // Block until W5500 is up and DHCP assigns an IP (30 s timeout)
    ESP_ERROR_CHECK(eth_connect(30000));

    samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
    ota.project_id      = OTA_PROJECT_ID;
    ota.api_key         = OTA_API_KEY;
    ota.current_version = FIRMWARE_VERSION;

    err = samfrevolt_ota_check_and_update(&ota);
    // ESP_OK → up to date | ESP_ERR_NOT_ALLOWED → device locked
    while (true) vTaskDelay(pdMS_TO_TICKS(1000));
}

Add the following to main/CMakeLists.txt:

idf_component_register(
    SRCS "main.c"
    REQUIRES nvs_flash esp_eth esp_netif esp_event driver samfrevolt_ota
)

Enable the W5500 driver in sdkconfig.defaults:

CONFIG_ETH_USE_SPI_ETHERNET=y
CONFIG_ETH_SPI_ETHERNET_W5500=y
The INT pin is required — the W5500 driver uses it to detect incoming frames. If it is not wired the driver will not receive data. The full bring-up code for eth_connect() is in examples/esp-idf-ethernet-w5500/main/main.c in the client library repo.
C++ — complete sketch
#include <WiFi.h>
#include <Preferences.h>
#include <SamfrevoltOTA.h>

#define FIRMWARE_VERSION  "1.0.0"
#define OTA_PROJECT_ID    "your-project-id"
#define OTA_API_KEY       "your-device-api-key"

void otaLog(const char *msg) { Serial.println(msg); }

void setup()
{
    Serial.begin(115200);
    delay(500);

    // ── 1. Connect to Wi-Fi ────────────────────────────────────────────
    Serial.print("Connecting");
    WiFi.begin("SSID", "PASSWORD");
    while (WiFi.status() != WL_CONNECTED) { Serial.print("."); delay(500); }
    Serial.println(" connected");

    // ── 2. Report result of previous update ───────────────────────────
    //    Preferences survives reboot. If the stored version differs from
    //    FIRMWARE_VERSION, an OTA happened on the previous boot.
    Preferences prefs;
    prefs.begin("ota", false);
    String prev = prefs.getString("prev_ver", "");
    if (prev.length() && prev != FIRMWARE_VERSION) {
        Serial.printf("Reporting update %s -> %s\n",
                      prev.c_str(), FIRMWARE_VERSION);
        SamfrevoltOTA.begin(OTA_PROJECT_ID, OTA_API_KEY)
                     .version(FIRMWARE_VERSION)
                     .onLog(otaLog)
                     .reportUpdate(prev.c_str(), FIRMWARE_VERSION, true);
    }
    prefs.putString("prev_ver", FIRMWARE_VERSION);  // persist for next boot
    prefs.end();

    // ── 3. Check for update ────────────────────────────────────────────
    //    If a newer version is available the device downloads, verifies
    //    SHA-256, flashes, and reboots — this call does not return then.
    bool upToDate = SamfrevoltOTA
        .begin(OTA_PROJECT_ID, OTA_API_KEY)
        .version(FIRMWARE_VERSION)
        .onLog(otaLog)
        .onUpdateAvailable([](const char *ver, bool mandatory) {
            Serial.printf("Update to %s available%s\n",
                          ver, mandatory ? " (mandatory)" : "");
            return true;  // return false here to skip this update
        })
        .checkAndUpdate();

    if (upToDate) {
        Serial.println("Firmware is current");
    } else {
        // false = network error, server error, or device identity locked
        Serial.println("OTA failed or device locked — check log above");
    }
}

void loop()
{
    // Your application code here.

    // ── Optional: re-check for updates every hour ──────────────────────
    // static uint32_t lastCheck = 0;
    // if (millis() - lastCheck > 3600000UL) {
    //     lastCheck = millis();
    //     SamfrevoltOTA.begin(OTA_PROJECT_ID, OTA_API_KEY)
    //                  .version(FIRMWARE_VERSION)
    //                  .onLog(otaLog)
    //                  .checkAndUpdate();
    // }
    delay(1000);
}
No server to run — Samfrevolt OTA is fully hosted at https://samfrevoltota.com. Create a free account, upload a .bin, and devices update automatically.

Installation

ESP-IDF Component

Two options — choose whichever fits your project structure:

Option A — shared across projects (recommended)

project/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
# Point at the repo root — exposes samfrevolt_ota and samfrevolt_gsm
set(EXTRA_COMPONENT_DIRS "/path/to/samfrevoltOta")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(your_project_name)
project/main/CMakeLists.txt
idf_component_register(
    SRCS "main.c"
    REQUIRES nvs_flash samfrevolt_ota
    # Add samfrevolt_gsm only if using GSM/LTE
)

Option B — copy component into project

cp -r /path/to/samfrevoltOta/samfrevolt_ota  your_project/components/
# Then add REQUIRES samfrevolt_ota in main/CMakeLists.txt

Build and flash:

idf.py set-target esp32s3   # or esp32, esp32s2, esp32c3 …
idf.py build flash monitor

Arduino Library

Install via Sketch → Include Library → Add .ZIP Library and select the repo root.
Or install from the Arduino Library Manager once published.

Required dependency: ArduinoJson (Benoit Blanchon) ≥ 6.x — install from Library Manager.

For GSM/LTE sketches: also install TinyGSM from Library Manager.

OTA Partition Table (ESP32)

The board must have an OTA-capable partition scheme with otadata, ota_0, and ota_1.

ToolHow to set
Arduino IDETools → Partition Scheme → Minimal SPIFFS (1.9 MB APP with OTA)
PlatformIOboard_build.partitions = default_ota.csv
ESP-IDFidf.py menuconfig → Partition Table → Custom CSV

Generate a custom partition CSV with the built-in tool:

cd tools/partition_designer
python main.py

Configuration

Config Reference

All settings live in samfrevolt_ota_config_t. Start from SAMFREVOLT_OTA_CONFIG_DEFAULT() and override only the fields you need.

C — minimal setup
samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
ota.project_id      = "YOUR_PROJECT_ID";   // required
ota.api_key         = "YOUR_API_KEY";      // required
ota.current_version = "1.2.3";            // required
FieldTypeDefaultDescription
project_idconst char*NULLRequired. Project ID from the Samfrevolt dashboard.
api_keyconst char*NULLRequired. Device API key from the dashboard Settings tab.
current_versionconst char*"0.0.0"Required. Firmware version running on this device, e.g. "1.2.3".
base_urlconst char*"https://samfrevoltota.com"Override when self-hosting. Set to http://… to enable HTTP mode.
device_idconst char*NULLUnique device identifier reported to the dashboard. Auto-generated as ESP32-AABBCCDDEEFF from eFuse MAC when NULL.
channelconst char*"stable"Release channel to poll. Options: "stable" | "beta" | "dev".
hw_revconst char*""Hardware revision string, e.g. "rev-b". Empty matches all revisions.
hw_idconst char*NULLHardware fingerprint. NULL = auto MAC hex, "" = opt out, custom string = override. See hw_id binding.
timeout_msuint32_t20000HTTP request timeout in milliseconds.

HTTP Mode

Set base_url to http:// and TLS is skipped automatically. The library logs a prominent 3-line warning on every call. Use only in development or trusted LAN environments.

HTTP mode transmits session tokens and firmware in plaintext. Anyone on the same network can intercept them. Never use HTTP in production.
ota.base_url = "http://192.168.1.50:5000";  // TLS skipped automatically
SamfrevoltOTA.begin(pid, key)
             .baseUrl("http://192.168.1.50:5000")
             .checkAndUpdate();

hw_id — Hardware Identity Binding

The server records the hw_id of each device on first contact and locks the device if that value ever changes on a subsequent check-in. This blocks the most common cloning attack: someone copies your firmware binary and API key onto a different chip — the eFuse MAC will differ, the server will detect the mismatch, and the clone gets locked immediately.

ValueBehaviour
NULL (default)Auto-derived from ESP32 eFuse MAC as 12-char lowercase hex (e.g. aabbccddeeff). Recommended.
""Opt out — hw_id is not sent; hardware binding is inactive for this device.
Custom stringOverride with any identifier. Must match ^[a-fA-F0-9:_.\-]{8,64}$.
The auto-derived hw_id is the same 6-byte eFuse MAC used for the auto-generated device_id, just formatted differently (aabbccddeeff vs ESP32-AABBCCDDEEFF). No extra hardware access — both read from the same eFuse register.

Production credential pattern

Hard-coding credentials in firmware source is fine for prototyping but wrong for production. Store the API key in NVS at provisioning time and read it at runtime:

// ── At provisioning (run once on the factory floor) ────────────────────
nvs_handle_t h;
nvs_open("cfg", NVS_READWRITE, &h);
nvs_set_str(h, "api_key", "THE_REAL_KEY_FROM_DASHBOARD");
nvs_commit(h);
nvs_close(h);

// ── At runtime (every boot) ─────────────────────────────────────────────
char api_key[64] = {0};
nvs_open("cfg", NVS_READONLY, &h);
nvs_get_str(h, "api_key", api_key, (size_t[]){sizeof(api_key)});
nvs_close(h);

samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
ota.project_id = "your-project-id";  // not a secret, OK in source
ota.api_key    = api_key;            // loaded from NVS, never in git
ota.current_version = FIRMWARE_VERSION;

API Reference

ESP-IDF API

esp_err_t samfrevolt_ota_check_and_update(const samfrevolt_ota_config_t *cfg)
Check for a firmware update and apply it if one is available.
Steps: build manifest URL → fetch JSON over HTTPS → check device_locked → compare versions → download binary → verify SHA-256 → set boot partition → esp_restart().
If an update is applied this function does not return — the device reboots. Call from a task with at least 8 KB of stack. Not re-entrant.
Return codeMeaning
ESP_OKNo update available; device is already current.
ESP_ERR_NOT_ALLOWEDDevice identity locked server-side. Contact project admin.
ESP_ERR_INVALID_ARGcfg or a required field is NULL/empty.
ESP_ERR_NO_MEMHeap allocation for manifest buffer failed.
ESP_ERR_NOT_FOUNDNo writable OTA partition found.
ESP_ERR_INVALID_RESPONSEManifest JSON malformed or missing required fields.
ESP_ERR_INVALID_CRCSHA-256 mismatch — download rejected.
ESP_ERR_INVALID_SIZEDownloaded size does not match manifest.
otherHTTP, TLS, or OTA partition layer error.
esp_err_t samfrevolt_ota_report(
    const samfrevolt_ota_config_t *cfg,
    const char *from_version,
    const char *to_version,
    bool success,
    const char *reason)
Report the result of the last OTA update to the server. Call this on the next boot after applying an update — not inside the same boot (the device reboots mid-flash).

Reporting success advances the server-side last_successful_version baseline, which enables version-regression detection and feeds fleet analytics. Optional but recommended.
Typical pattern — NVS
nvs_handle_t nvs;
nvs_open("ota", NVS_READWRITE, &nvs);

char prev[32] = {0};
if (nvs_get_str(nvs, "prev_ver", prev, sizeof(prev)) == ESP_OK &&
    strcmp(prev, FIRMWARE_VERSION) != 0) {
    // Version changed → an OTA happened on the previous boot
    samfrevolt_ota_report(&ota, prev, FIRMWARE_VERSION, true, NULL);
}
nvs_set_str(nvs, "prev_ver", FIRMWARE_VERSION);  // persist for next boot
nvs_commit(nvs);
nvs_close(nvs);
Returns ESP_OK when the report was delivered. Other values are network errors — non-fatal, the device continues normally.
esp_err_t samfrevolt_wifi_connect(const char *ssid, const char *password, uint32_t timeout_ms)
Connect to a Wi-Fi network in STA mode and wait for an IP address. Handles esp_netif_init(), default event loop creation, and the Wi-Fi event group internally — you do not need any boilerplate. Safe to call even if your application already initialised those before calling this helper.
Returns ESP_OK when connected and IP is assigned. ESP_FAIL after max retries. ESP_ERR_TIMEOUT when no IP received within timeout_ms.

GSM/LTE — cellular network bring-up

For SIM7600 and compatible modems use the separate samfrevolt_gsm component. Requires esp_modem: run idf.py add-dependency espressif/esp_modem once.

#include "samfrevolt_gsm.h"
#include "samfrevolt_ota.h"

void app_main(void)
{
    nvs_flash_init();

    // ── Bring up GSM PPP ───────────────────────────────────────────────
    samfrevolt_gsm_config_t gsm = SAMFREVOLT_GSM_CONFIG_DEFAULT();
    gsm.apn     = "internet";   // your SIM card APN
    gsm.tx_gpio = 17;
    gsm.rx_gpio = 18;
    // gsm.power_gpio = 4;   // optional modem power pin
    ESP_ERROR_CHECK(samfrevolt_gsm_connect(&gsm));

    // ── OTA over cellular ──────────────────────────────────────────────
    samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
    ota.project_id      = OTA_PROJECT_ID;
    ota.api_key         = OTA_API_KEY;
    ota.current_version = FIRMWARE_VERSION;
    ota.timeout_ms      = 30000;   // increase for slow LTE connections

    samfrevolt_ota_check_and_update(&ota);
}

Add both components to main/CMakeLists.txt:

idf_component_register(
    SRCS "main.c"
    REQUIRES nvs_flash samfrevolt_ota samfrevolt_gsm
)

Ethernet W5500 PoE — wired network bring-up

The W5500 is an external SPI Ethernet controller with an integrated PHY. When paired with a PoE front-end module the board draws power directly from the Ethernet cable — no Wi-Fi, no GSM, no external power supply needed.

The samfrevolt_ota library is transport-agnostic: once the W5500 is up and DHCP has assigned an IP, the OTA call is identical to the Wi-Fi path.

#include "esp_eth.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "driver/spi_master.h"
#include "samfrevolt_ota.h"

// ── Adjust pin assignments to match your PCB ──────────────────────
#define ETH_SPI_HOST    SPI2_HOST
#define ETH_SPI_CLK_MHZ 20       // 20 MHz is conservative; W5500 supports up to 80 MHz
#define ETH_SPI_MISO    19
#define ETH_SPI_MOSI    23
#define ETH_SPI_CLK     18
#define ETH_SPI_CS       5
#define ETH_INT_GPIO    26       // required — interrupt line to detect frames/link events
#define ETH_RST_GPIO    25       // use -1 if reset pin is not wired

void app_main(void)
{
    nvs_flash_init();

    // eth_connect() initialises the SPI bus, installs the W5500 MAC+PHY driver,
    // attaches it to the TCP/IP stack, and blocks until DHCP assigns an IP.
    // Full source: examples/esp-idf-ethernet-w5500/main/main.c
    ESP_ERROR_CHECK(eth_connect(30000));   // 30 s timeout

    samfrevolt_ota_config_t ota = SAMFREVOLT_OTA_CONFIG_DEFAULT();
    ota.project_id      = OTA_PROJECT_ID;
    ota.api_key         = OTA_API_KEY;
    ota.current_version = FIRMWARE_VERSION;
    samfrevolt_ota_check_and_update(&ota);
}

main/CMakeLists.txt:

idf_component_register(
    SRCS "main.c"
    REQUIRES nvs_flash esp_eth esp_netif esp_event driver samfrevolt_ota
)

Enable the W5500 driver — add a sdkconfig.defaults to your project:

CONFIG_ETH_USE_SPI_ETHERNET=y
CONFIG_ETH_SPI_ETHERNET_W5500=y
The INT pin must be wired — without it the driver cannot detect incoming data or link events. See the full eth_connect() implementation in examples/esp-idf-ethernet-w5500/ in the client library repository. A PoE switch or injector (802.3af / 802.3at) is required to power the board through the cable.

Arduino API

The Arduino library exposes a global singleton SamfrevoltOTA with a fluent (chainable) interface. Call .begin() first, chain any optional setters, then call the action.

Fluent setters

MethodDefaultDescription
.begin(projectId, apiKey)Required. Sets credentials and auto-derives device ID + hw_id from eFuse MAC.
.version(ver)"0.0.0"Firmware version currently running on this device.
.channel(ch)"stable"Release channel: "stable" | "beta" | "dev".
.hardwareRevision(rev)""Hardware revision string, e.g. "rev-b".
.deviceId(id)auto MACOverride the device identifier reported to the dashboard.
.hwId(id)auto MACHardware fingerprint. Pass "" to opt out of identity binding.
.baseUrl(url)https://samfrevoltota.comOverride when self-hosting. Set to http://… for HTTP mode.
.rootCA(pem)ISRG Root X1PEM root CA for TLS. Override when self-hosting with a custom cert.
.onLog(fn)nullptrCallback void fn(const char*) for progress and error messages.
.onUpdateAvailable(fn)nullptrCallback bool fn(const char *ver, bool mandatory). Return false to skip.
bool SamfrevoltOTA.checkAndUpdate()
Fetch manifest, compare version, download, verify SHA-256, flash, and reboot.
If an update is applied this function does not return.
true — device is already up to date.
false — check failed, network error, verification error, or device identity locked.
Example with confirmation callback
SamfrevoltOTA
    .begin(PROJECT_ID, API_KEY)
    .version(FIRMWARE_VERSION)
    .onLog([](const char *m){ Serial.println(m); })
    .onUpdateAvailable([](const char *ver, bool mandatory) {
        Serial.printf("Update to %s available — proceed? %s\n",
                      ver, mandatory ? "mandatory" : "optional");
        return true;  // return false to skip
    })
    .checkAndUpdate();
bool SamfrevoltOTA.reportUpdate(fromVersion, toVersion, success, reason = nullptr)
Report the result of the last OTA update to the server. Call on the next boot after an update — not in the same boot where checkAndUpdate() was called, because the device reboots mid-flash.

Reporting success advances the server's last_successful_version baseline, which enables version-regression detection and populates fleet analytics (success / failed / pending counts).
true — report delivered. false — network error, non-fatal, device continues.
Typical pattern — Preferences
Preferences prefs;
prefs.begin("ota", false);
String prev = prefs.getString("prev_ver", "");

if (prev.length() && prev != FIRMWARE_VERSION) {
    // Version changed since last boot → OTA happened, report success
    SamfrevoltOTA.begin(PROJECT_ID, API_KEY)
                 .version(FIRMWARE_VERSION)
                 .onLog([](const char *m){ Serial.println(m); })
                 .reportUpdate(prev.c_str(), FIRMWARE_VERSION, true);
}
prefs.putString("prev_ver", FIRMWARE_VERSION);  // persist for next boot
prefs.end();
Reporting a failed update
// If your app detects a problem after booting the new firmware:
SamfrevoltOTA.begin(PROJECT_ID, API_KEY)
             .version(FIRMWARE_VERSION)
             .reportUpdate(prevVersion, FIRMWARE_VERSION,
                           false,            // success = false
                           "self_test_fail"); // reason string

Arduino GSM/LTE example

Use TinyGSM to bring up the modem, then the same fluent OTA chain:

#define TINY_GSM_MODEM_SIM7600
#include <TinyGsmClient.h>
#include <SamfrevoltOTA.h>

HardwareSerial modemSerial(1);
TinyGsm        modem(modemSerial);
TinyGsmClient  gsmClient(modem);

void setup()
{
    Serial.begin(115200);
    modemSerial.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN);

    modem.restart();
    modem.gprsConnect("internet", "", "");   // APN, user, pass
    while (!modem.isGprsConnected()) delay(1000);
    Serial.println("GPRS connected");

    // OTA over GSM — pass your TinyGsmClient via .rootCA() override not needed;
    // use the standard call, the library opens its own HTTP stack
    bool ok = SamfrevoltOTA
        .begin(OTA_PROJECT_ID, OTA_API_KEY)
        .version(FIRMWARE_VERSION)
        .onLog([](const char *m){ Serial.println(m); })
        .checkAndUpdate();

    Serial.println(ok ? "Up to date" : "Check failed");
}

Protocol

Protocol Reference

The device protocol is plain HTTPS + JSON. Two mandatory endpoints and one optional report endpoint.

Manifest Check

GET /p/{project_id}/ota/check
ParameterRequiredDescription
device_idYesStable unique identifier for this device.
api_keyYesDevice API key from the project Settings tab.
current_versionYesFirmware version currently running.
channelRecommendedRelease channel: stable | beta | dev.
hw_revOptionalHardware revision. Empty = match all revisions.
hw_idOptionalHardware fingerprint for identity binding (auto-sent by the library).

Responses

No update available

{
  "update_available": false,
  "channel": "stable",
  "notes": "Already up to date"
}

Update available

{
  "update_available": true,
  "version": "1.4.0",
  "channel": "stable",
  "url": "https://samfrevoltota.com/p/{pid}/ota/firmware/stable/fw_1.4.0.bin",
  "size": 1572864,
  "sha256": "a3f5c9d1e8b2044f…",
  "mandatory": false,
  "notes": "Release notes visible to the device"
}
FieldDescription
update_availabletrue when the device should download a new image.
versionVersion string of the offered release.
urlFull HTTPS URL to the firmware binary.
sizeExpected binary size in bytes.
sha256Expected SHA-256 digest, lowercase hex.
mandatoryWhen true, the library treats this update as required by policy.

Device locked

{
  "update_available": false,
  "device_locked": true,
  "lock_reason": "hw_mismatch",
  "notes": "Identity locked — contact project admin"
}

Update Report

POST /p/{project_id}/ota/report
Content-Type: application/json
{
  "device_id":    "ESP32-AABBCCDDEEFF",
  "api_key":      "YOUR_DEVICE_API_KEY",
  "status":       "success",
  "from_version": "1.0.0",
  "to_version":   "1.1.0"
}

Set "status": "failed" and add "reason": "sha256_mismatch" for failed updates.

Full Update Flow

Sequence — OTA update cycle
sequenceDiagram participant D as Device participant S as Server D->>S: GET /ota/check
?device_id=ESP32-AA…
¤t_version=1.0.0
&hw_id=aabbccddeeff S->>S: Identity check
Version compare alt device_locked = true S-->>D: {device_locked: true,
lock_reason: hw_mismatch} Note over D: Log warning, return
ESP_ERR_NOT_ALLOWED else No update S-->>D: {update_available: false} Note over D: Return ESP_OK else Update available S-->>D: {update_available: true,
url, sha256, size} D->>S: GET /ota/firmware/… S-->>D: Binary stream D->>D: SHA-256 verify D->>D: Flash inactive OTA slot D->>D: esp_restart() Note over D: Boots new firmware D->>S: POST /ota/report
{status: success} S-->>D: 200 OK end

Security

Security

API Key

The device API key authenticates your device to the server. Treat it like a password.

Never hard-code the API key in firmware source files. Never commit it to a repository. Store it in NVS or secure storage at provisioning time.
// Set at provisioning time — not in firmware source
nvs_handle_t h;
nvs_open("samfrevolt", NVS_READWRITE, &h);
nvs_set_str(h, "api_key", "YOUR_DEVICE_API_KEY");
nvs_commit(h);
nvs_close(h);

Rotate the key from the project panel (Settings → API Key → Rotate) if you believe it has been exposed. After rotation, flash the new key to all active devices before the old key is revoked.

TLS

All communication with https://samfrevoltota.com uses TLS 1.2+. The ESP-IDF component uses the ESP-IDF certificate bundle (esp_crt_bundle_attach), which contains hundreds of root CAs and is updated with each IDF release — no manual certificate management needed.

The Arduino library embeds ISRG Root X1 (Let's Encrypt) by default, which covers the hosted service. Override with .rootCA(myPem) only when self-hosting with a different CA.

Never disable certificate verification in production. A device that skips TLS validation can be pointed at a rogue server and served an arbitrary firmware binary — with no way to detect it.

SHA-256 Verification

The library computes SHA-256 while streaming — no second download pass, no extra memory buffer. Every received byte is fed into the hash context as it is written to the OTA partition. After the download completes, the digest is compared to the sha256 field from the manifest.

If the digests do not match, Update.abort() / esp_ota_abort() is called immediately — the partition is left in an invalid state that the bootloader will not boot from. The device stays on its current firmware. The error is logged and returned to the caller.

SHA-256 verification is always active and cannot be disabled. A mismatched digest means either the binary was corrupted in transit or it was tampered with — either way the device correctly refuses to flash it.

Hardware Identity Binding

The threat model: an attacker extracts your firmware binary and the API key from one device, flashes it onto different hardware, and presents it to your OTA server as a legitimate device. The hw_id binding stops this: the attacker's chip has a different eFuse MAC, so its hw_id differs from the one recorded on first contact. The server locks it immediately and notifies you.

When hw_id = NULL (the default), the library reads the 6-byte eFuse MAC and formats it as a 12-character lowercase hex string (e.g. aabbccddeeff). This value is appended to every manifest check request. The server records it silently on first contact and compares on every subsequent request.

Flowchart — identity check on every manifest request
flowchart TD A([Device check-in]) --> B{Device known?} B -- No --> C{strict_mode\nenabled?} C -- Yes --> BLK[Block: not_registered] C -- No --> REG[Register device\nrecord hw_id] B -- Yes --> F{device_locked?} F -- Yes --> LOCK[Return locked response] F -- No --> G{hw_id_binding\nenabled?} G -- Yes --> H{hw_id matches\nrecord?} H -- No --> LHW[Lock + notify admin\nreason: hw_mismatch] H -- Yes --> VER{Version regression\ncheck} G -- No --> VER VER -- Regression --> LVER[Lock + notify admin\nreason: version_regression] VER -- OK --> OK([Serve manifest ✓]) REG --> OK

Device Locking

Lock reasons returned by the server:

lock_reasonCause
hw_mismatchhw_id changed since first registration.
version_regressionDevice reported a version lower than its last confirmed version.
clone_detectedSame hw_id seen from two concurrent IPs in a short window.
not_registeredDevice not in provisioned fleet and strict mode is active.
manualAdmin locked the device manually from the dashboard.

To unlock a device: Dashboard → Project → Devices → select device → Reset Lock.

Firmware Signing (paid plans)

If your project has Ed25519 signing enabled, the manifest includes a signature over the SHA-256 digest. The library verifies this signature using the public key embedded in firmware at compile time.

This verifies authenticity (binary was signed by your server's key) in addition to integrity (binary was not corrupted in transit).

Enable in menuconfig: CONFIG_SAMFREVOLT_OTA_REQUIRE_SIGNATURE=y
Embed the public key: Dashboard → Project → Settings → Firmware Signing → Copy public key.

Rate Limits

EndpointLimit
/ota/check120 req / min
/ota/firmware/30 req / min
/auth/login10 req / min
/auth/forgot-password5 req / min

The library backs off automatically when it receives a 429 response and respects the Retry-After header. Do not implement your own tight retry loop.


ESP32 Notes

ESP32 Notes

OTA Partition Scheme

The board must have been compiled and flashed with an OTA-capable partition table. The uploaded .bin must be smaller than the OTA slot size defined in your CSV.

PartitionPurpose
otadata2 KB metadata area — tracks which OTA slot is active and its verification state.
ota_0 / app0First firmware slot. The device boots from here initially.
ota_1 / app1Second slot — the library writes the new image here while the device keeps running from ota_0.
The uploaded .bin must fit inside a single OTA slot. A typical ESP32 with a 4 MB flash and the default OTA partition table gives you ~1.8 MB per slot. Check your partition CSV if you get "image too large" errors. Use the partition designer tool to generate a custom CSV.

Example custom partition CSV (4 MB flash, 1.9 MB OTA slots):

# Name,   Type, SubType, Offset,  Size,   Flags
nvs,       data, nvs,     0x9000,  0x5000,
otadata,   data, ota,     0xe000,  0x2000,
app0,      app,  ota_0,   0x10000, 0x1F0000,
app1,      app,  ota_1,   0x200000,0x1F0000,
spiffs,    data, spiffs,  0x3F0000,0x10000,

Components

ComponentUse when
samfrevolt_ota/Always — core OTA + Wi-Fi helper.
samfrevolt_gsm/Cellular devices only; requires esp_modem.
CMakeLists.txt
set(EXTRA_COMPONENT_DIRS "/path/to/samfrevoltOta")

# main/CMakeLists.txt
idf_component_register(
    SRCS "main.c"
    REQUIRES nvs_flash samfrevolt_ota   # add samfrevolt_gsm for cellular
)

Boot Confirmation & Rollback

After samfrevolt_ota_check_and_update() flashes the new image and calls esp_restart(), the ESP-IDF bootloader switches to the new partition but marks it pending verification. This state means: "new image booted but not yet confirmed safe."

Your application must call this once it has confirmed normal operation:

#include "esp_ota_ops.h"

void app_main(void)
{
    // ... NVS init, WiFi connect, OTA report, etc. ...

    // Once your app confirms it is working correctly, mark the image valid.
    // Without this call, ESP-IDF will roll back to the previous firmware
    // on the next reboot (power cycle, watchdog, panic, etc.).
    esp_ota_mark_app_valid_cancel_rollback();

    // Your application continues...
}
Call esp_ota_mark_app_valid_cancel_rollback() only after your app has verified it works — not immediately at boot. If a buggy update crashes before confirming, the device rolls back to the last known-good firmware automatically. This means a bricked update cannot permanently damage a remote device.

On Arduino the equivalent is:

#include <esp_ota_ops.h>

// At the end of setup(), after all self-tests pass:
esp_ota_mark_app_valid_cancel_rollback();
Flowchart — boot confirmation and rollback
flowchart LR A([OTA flash complete]) --> B[esp_restart] B --> C[Boot new firmware\npending-verify state] C --> D{App self-test} D -- Pass --> E[esp_ota_mark_app_valid\n_cancel_rollback] D -- Fail or\ntimeout --> F[ESP-IDF auto-rollback] E --> G([Running new firmware ✓]) F --> H([Previous firmware restored])

Cellular (GSM/LTE)

For SIM7600 and compatible modems, use the samfrevolt_gsm component:

#include "samfrevolt_gsm.h"

samfrevolt_gsm_config_t gsm = SAMFREVOLT_GSM_CONFIG_DEFAULT();
gsm.apn     = "internet";
gsm.tx_gpio = 17;
gsm.rx_gpio = 18;
ESP_ERROR_CHECK(samfrevolt_gsm_connect(&gsm));
On metered data plans: check the manifest first and download only when update_available == true. Set timeout_ms to 30 000+ ms for LTE. Avoid checking more frequently than once per hour or per boot.

Fleet & Devices

Fleet & Devices

Auto Device ID

If you do not set a device_id, the library derives one from the ESP32 eFuse base MAC — 6 bytes burned at the factory, unique per chip and immutable:

// Format: "ESP32-AABBCCDDEEFF"
// Auto-used when ota.device_id = NULL  (the default)

You can override this with a custom ID stored in NVS at provisioning time — useful when you need human-readable identifiers like serial numbers.

Fleet Limits

PlanMax devicesChannels
Free2stable
AnnualUnlimitedstable, beta, dev
LifetimeUnlimitedstable, beta, dev

When the fleet is full, the server returns a fleet_limit_reached error. The library logs the error and does not flash anything — OTA being unavailable is non-fatal. To free a slot: Dashboard → Project → Devices → Delete.

Device Provisioning (paid plans)

Pre-register devices before shipping so the server recognises them on first contact. Pair provisioning with strict mode to block any device that wasn't pre-registered.

Provision from the dashboard: Project → Devices → Provision new device.
Optionally bind a specific hw_id at provisioning time — the server will lock the device if a different chip attempts to use the same device ID.

Flowchart — provisioning and first check-in
flowchart TD A([Admin provisions device\nin dashboard]) --> B[Server records device\nidentity_status: provisioned\noptional provisioned_hw_id] B --> C([Device ships]) C --> D([First check-in]) D --> E{hw_id matches\nprovisioned_hw_id?} E -- Yes or not set --> F[Transition → active\nserve manifest normally] E -- No --> G[Lock device\nreason: hw_mismatch\nnotify admin]