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:
- Builds the manifest URL — project ID, device ID, version, channel, hw_id, hw_rev
- Opens HTTPS to the server (TLS with ISRG Root X1; plain HTTP if
base_urlstarts withhttp://) - Parses the JSON manifest
- If
device_locked: true→ logs the lock reason, returnsESP_ERR_NOT_ALLOWED - If
update_available: false→ returnsESP_OKimmediately, nothing downloaded - Opens the inactive OTA partition (the device keeps running from the current one)
- Streams the firmware binary in chunks, computing SHA-256 over every received byte simultaneously
- Compares the computed digest against the manifest
sha256field — aborts + discards if mismatch - Calls
esp_ota_set_boot_partition()to arm the new image for next boot - Calls
esp_restart()— the function never returns on a successful update
#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));
}
#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
eth_connect() is in examples/esp-idf-ethernet-w5500/main/main.c in the client library repo.#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);
}
.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.txtcmake_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.
| Tool | How to set |
|---|---|
| Arduino IDE | Tools → Partition Scheme → Minimal SPIFFS (1.9 MB APP with OTA) |
| PlatformIO | board_build.partitions = default_ota.csv |
| ESP-IDF | idf.py menuconfig → Partition Table → Custom CSV |
Generate a custom partition CSV with the built-in tool:
cd tools/partition_designer
python main.py
Config Reference
All settings live in samfrevolt_ota_config_t. Start from
SAMFREVOLT_OTA_CONFIG_DEFAULT() and override only the fields you need.
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
| Field | Type | Default | Description |
|---|---|---|---|
project_id | const char* | NULL | Required. Project ID from the Samfrevolt dashboard. |
api_key | const char* | NULL | Required. Device API key from the dashboard Settings tab. |
current_version | const char* | "0.0.0" | Required. Firmware version running on this device, e.g. "1.2.3". |
base_url | const char* | "https://samfrevoltota.com" | Override when self-hosting. Set to http://… to enable HTTP mode. |
device_id | const char* | NULL | Unique device identifier reported to the dashboard. Auto-generated as ESP32-AABBCCDDEEFF from eFuse MAC when NULL. |
channel | const char* | "stable" | Release channel to poll. Options: "stable" | "beta" | "dev". |
hw_rev | const char* | "" | Hardware revision string, e.g. "rev-b". Empty matches all revisions. |
hw_id | const char* | NULL | Hardware fingerprint. NULL = auto MAC hex, "" = opt out, custom string = override. See hw_id binding. |
timeout_ms | uint32_t | 20000 | HTTP 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.
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.
| Value | Behaviour |
|---|---|
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 string | Override with any identifier. Must match ^[a-fA-F0-9:_.\-]{8,64}$. |
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;
ESP-IDF API
Steps: build manifest URL → fetch JSON over HTTPS → check device_locked → compare versions → download binary → verify SHA-256 → set boot partition →
esp_restart().
| Return code | Meaning |
|---|---|
ESP_OK | No update available; device is already current. |
ESP_ERR_NOT_ALLOWED | Device identity locked server-side. Contact project admin. |
ESP_ERR_INVALID_ARG | cfg or a required field is NULL/empty. |
ESP_ERR_NO_MEM | Heap allocation for manifest buffer failed. |
ESP_ERR_NOT_FOUND | No writable OTA partition found. |
ESP_ERR_INVALID_RESPONSE | Manifest JSON malformed or missing required fields. |
ESP_ERR_INVALID_CRC | SHA-256 mismatch — download rejected. |
ESP_ERR_INVALID_SIZE | Downloaded size does not match manifest. |
| other | HTTP, TLS, or OTA partition layer error. |
const samfrevolt_ota_config_t *cfg,
const char *from_version,
const char *to_version,
bool success,
const char *reason)
Reporting success advances the server-side
last_successful_version baseline,
which enables version-regression detection and feeds fleet analytics. Optional but recommended.
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);
ESP_OK when the report was delivered.
Other values are network errors — non-fatal, the device continues normally.
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.
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
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
| Method | Default | Description |
|---|---|---|
.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 MAC | Override the device identifier reported to the dashboard. |
.hwId(id) | auto MAC | Hardware fingerprint. Pass "" to opt out of identity binding. |
.baseUrl(url) | https://samfrevoltota.com | Override when self-hosting. Set to http://… for HTTP mode. |
.rootCA(pem) | ISRG Root X1 | PEM root CA for TLS. Override when self-hosting with a custom cert. |
.onLog(fn) | nullptr | Callback void fn(const char*) for progress and error messages. |
.onUpdateAvailable(fn) | nullptr | Callback bool fn(const char *ver, bool mandatory). Return false to skip. |
If an update is applied this function does not return.
false — check failed, network error, verification error, or device identity locked.
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();
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).
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 Reference
The device protocol is plain HTTPS + JSON. Two mandatory endpoints and one optional report endpoint.
Manifest Check
GET /p/{project_id}/ota/check
| Parameter | Required | Description |
|---|---|---|
device_id | Yes | Stable unique identifier for this device. |
api_key | Yes | Device API key from the project Settings tab. |
current_version | Yes | Firmware version currently running. |
channel | Recommended | Release channel: stable | beta | dev. |
hw_rev | Optional | Hardware revision. Empty = match all revisions. |
hw_id | Optional | Hardware 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"
}
| Field | Description |
|---|---|
update_available | true when the device should download a new image. |
version | Version string of the offered release. |
url | Full HTTPS URL to the firmware binary. |
size | Expected binary size in bytes. |
sha256 | Expected SHA-256 digest, lowercase hex. |
mandatory | When 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
?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
API Key
The device API key authenticates your device to the server. Treat it like a password.
// 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.
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.
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.
Device Locking
Lock reasons returned by the server:
| lock_reason | Cause |
|---|---|
hw_mismatch | hw_id changed since first registration. |
version_regression | Device reported a version lower than its last confirmed version. |
clone_detected | Same hw_id seen from two concurrent IPs in a short window. |
not_registered | Device not in provisioned fleet and strict mode is active. |
manual | Admin 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
| Endpoint | Limit |
|---|---|
/ota/check | 120 req / min |
/ota/firmware/ | 30 req / min |
/auth/login | 10 req / min |
/auth/forgot-password | 5 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
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.
| Partition | Purpose |
|---|---|
otadata | 2 KB metadata area — tracks which OTA slot is active and its verification state. |
ota_0 / app0 | First firmware slot. The device boots from here initially. |
ota_1 / app1 | Second slot — the library writes the new image here while the device keeps running from ota_0. |
.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
| Component | Use when |
|---|---|
samfrevolt_ota/ | Always — core OTA + Wi-Fi helper. |
samfrevolt_gsm/ | Cellular devices only; requires esp_modem. |
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...
}
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();
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));
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
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
| Plan | Max devices | Channels |
|---|---|---|
| Free | 2 | stable |
| Annual | Unlimited | stable, beta, dev |
| Lifetime | Unlimited | stable, 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.