OTA
IOTMER firmware updates use two trigger paths and one download plane.
| Path | Trigger | Who implements it | Binary transport |
|---|---|---|---|
| HTTP(S) OTA | Provision JSON (firmware_url + SHA256) | SDK (iotmer_ota.c, IOTMER_AUTO_OTA) | HTTPS (esp_https_ota) |
| MQTT OTA | MQTT cmd / cmd/device with "cmd":"ota" | Application worker | HTTP(S) Range / GET — not MQTT payload |
MQTT never carries the .bin. The broker only delivers the command (URL + optional SHA). The device then fetches the image with an HTTP client.
Cloud / console
│
├─ HTTPS provision → firmware_url + firmware_checksum_sha256
│ │
│ ▼
│ iotmer_ota_apply_if_needed() [SDK]
│
└─ MQTT cmd → {"cmd":"ota","url":"…","sha256":"…"}
│
▼
app OTA worker + HTTP(S) download [application]
│
▼
ota_0 / ota_1 → set_boot_partition → reboot
BLE OTA is not part of this SDK. Products may reject OTA over GATT. If a BLE session is open during MQTT OTA, release the radio before TLS download so heap is available.
Shared rules (both paths)
- Dual app slots (
ota_0/ota_1) plusotadata.iotmer doctorwarns when the table is not OTA-capable. - Prefer HTTPS URLs. TLS uses
iotmer_tls_get_ca_cert_pem()when pinned, otherwiseesp_crt_bundle_attach. A CDN cert that is not in the pin/bundle fails handshake. - Production images should always ship a 64-hex SHA256. ESP-IDF
esp_ota_endonly checks ESP image format/CRC — it does not prove you got the file you intended. - Last successful digest is stored in NVS (
firmware_applied_sha256, short keyfw_applied_sha256). Same SHA is skipped unless the path explicitly forces a re-download. - Image size must fit the inactive OTA slot.
- OTA opens a second TLS session. On low-RAM chips:
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y, suspend BLE,IOTMER_TLS_MIN_HEAP_GUARD+on_tls_acquire.
HTTP(S) OTA (SDK auto-OTA)
Enabled with CONFIG_IOTMER_AUTO_OTA=y (default). After provision (or firmware poll), if NVS/provision holds both firmware_url and firmware_checksum_sha256, the SDK calls iotmer_ota_apply_if_needed().
Typical boot order (iotmer_init()):
Wi-Fi → NVS load → HTTPS provision → NVS save → iotmer_ota_apply_if_needed() → MQTT connect
API: iotmer_ota_apply_if_needed(creds, after_https_provision).
When IOTMER_AUTO_OTA is n, the function is a no-op. Field products that only update via MQTT command should turn auto-OTA off so a stale firmware_url in NVS does not reboot the device on every boot.
When it runs
| Condition | Result |
|---|---|
Empty firmware_url | Skip (log). Checksum without URL is a warning (truncated URL / JSON). |
Empty firmware_checksum_sha256 | Skip — SDK requires SHA for auto-OTA. |
SHA equals NVS firmware_applied_sha256 | Skip, unless force (below). |
| URL + SHA present and SHA differs | Download, verify, save SHA, esp_restart(). |
Force re-download (same SHA):
| Method | When |
|---|---|
IOTMER_OTA_APPLY_EVEN_IF_SAME_SHA=y | Factory images |
| HTTPS provision on the same boot | SDK passes after_https_provision=true |
IOTMER_FIRMWARE_POLL re-calls provision when URL/SHA are missing or OTA rejected the image (ESP_ERR_INVALID_VERSION, ESP_ERR_OTA_VALIDATE_FAILED) so a fixed CDN file can be picked up without a manual reboot.
Download and SHA256
Uses esp_https_ota_begin / perform / finish (esp_https_ota). Timeout: IOTMER_OTA_TIMEOUT_MS (default 120 s).
Before activating the image the SDK:
- Locates the next update partition (
esp_ota_get_next_update_partition) - Reads the written bytes back
- Computes SHA256 (PSA Crypto on IDF ≥ 6, else mbedTLS)
- Compares case-insensitively to
firmware_checksum_sha256
Mismatch → esp_https_ota_abort, ESP_ERR_OTA_VALIDATE_FAILED. Running firmware is unchanged.
On success the digest is copied to firmware_applied_sha256, NVS is saved, then the chip reboots.
Provision fields: HTTPS provisioning. Kconfig: Kconfig.
MQTT OTA (command → HTTP(S) download)
Use this path for in-field updates after MQTT is up. The SDK does not parse "cmd":"ota"; the application subscribes with iotmer_subscribe_commands() (or cmd/#) and runs a dedicated worker.
Reference behaviour (queue, acks, Range download, rollback): field firmware such as dual-slot ESP32 products. Binary still comes from url over HTTP(S).
Architecture
Cloud / mobile
│ MQTT QoS 1
▼
{prefix}/cmd/device {"cmd":"ota","url":"…","sha256":"…","force":false}
│ (or {prefix}/cmd + "ch":"device")
▼
Command handler → mqtt_ota_start()
│
▼
FreeRTOS queue (length 1) + worker task
│
├─ ack: queued (handler accepted the request)
├─ ack: starting (worker took the job, before download)
├─ HTTP(S) Range / GET → inactive ota slot
├─ SHA256 (recommended) → set_boot_partition
├─ ack: applied_rebooting
└─ esp_restart()
prefix = {workspace_slug}/{device_key} (see MQTT topics).
Dispatch immediately in the MQTT callback for ota (do not sit behind a busy telemetry/config job queue). The OTA-specific queue is separate and has one slot.
Command (cloud → device)
| Topic | {prefix}/cmd/device |
| Alternative | {prefix}/cmd + "ch":"device" (console root command topic) |
| QoS | 1 |
| Retain | 0 |
| BLE | Do not start OTA from BLE |
{
"cmd": "ota",
"url": "https://example.com/firmware.bin",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"force": false
}
| Field | Required | Notes |
|---|---|---|
cmd | Yes | "ota" |
url | Yes* | Firmware .bin HTTP(S) URL. Cap ~511 characters. |
firmware_url | No | Alias if url is absent (same name as provision). |
sha256 | Recommended | 64 hex. If missing, skip integrity check (log it). |
force | No | true skips NVS “already applied” SHA check. |
* Neither url nor firmware_url → ack ok=false, message=missing_url.
Invalid SHA length → ok=false, message=invalid_sha256.
Do not require command_id on OTA acks.
Ack (device → cloud)
| Topic | {prefix}/cmd/ack/device |
| QoS | 1 |
| Retain | 0 |
{
"cmd": "ota",
"ok": true,
"message": "queued",
"ts": 1718280000.5
}
ok + message describe the phase. One command produces several acks. Clients must wait for a terminal message (applied_rebooting, already_applied, or ok=false). The first queued is not completion.
After starting, disconnect MQTT so the download TLS session has heap. Reconnect (iotmer_connect) before applied_rebooting or error acks. If reconnect fails, keep serial logs; the ack may be dropped.
If BLE notify is wired, cmd.ack may also go out over GATT. Trigger remains MQTT. Stop advertising and release BLE before download.
Firmware fetch (device → CDN)
| Scheme | Client |
|---|---|
https:// | TLS (pinned PEM or CA bundle) |
http:// | Plain HTTP only if the product enables it (e.g. CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP). Prefer HTTPS in production. |
Size: HEAD Content-Length, else GET Range: bytes=0-0 and Content-Range (bytes …/TOTAL).
Download in paced chunks (e.g. 16 KB Range: bytes=start-end). The origin should return 206 (or 200 for a full body). Cloudflare/R2-style object storage works with this Range loop; a single unbounded GET can starve Wi‑Fi/WDT on small chips.
HTTP timeout on the order of 120 s per request; short pause between chunks.
Queue model (queued vs starting)
queued means the request is in the OTA queue (length 1), not that flash writing started.
mqtt_ota_start() | Ack | Meaning |
|---|---|---|
ESP_OK | ok=true, message=queued | Accepted; download not started |
ESP_ERR_INVALID_STATE | ok=false, message=ota_busy | Queue full (OTA already pending or running) |
ESP_ERR_INVALID_ARG | missing_url / invalid_sha256 | Handler validation |
ESP_ERR_NO_MEM | esp_err_to_name | Queue/task alloc failed |
Worker (xQueueReceive):
- Same SHA as NVS and
force==false→ok=true,message=already_applied(no download). - No OTA partition →
ok=false,message=no_ota_partition. - Else first
ok=true,message=startingwhile MQTT is still up. - Then mark OTA active and prepare for download (Wi‑Fi PS off, BLE radio off, MQTT disconnect).
Publish the string starting, not started.
While OTA is active: skip telemetry/heartbeat/config jobs that contend for heap/CPU. After starting, MQTT silence until reconnect is expected.
Low internal heap before HTTPS (e.g. largest block < 16 KB HTTPS / < 6 KB HTTP) → ok=false, message=low_heap. MQTT may already be down; retry ack after reconnect.
End-to-end sequence
sequenceDiagram
participant Cloud as Cloud/mobile
participant MQTT as Broker
participant ESP as Device
participant CDN as HTTP(S) URL
Cloud->>MQTT: cmd/device ota
MQTT->>ESP: subscribe cmd/#
ESP->>MQTT: cmd/ack/device queued
Note over ESP: OTA queue (1 slot)
ESP->>MQTT: cmd/ack/device starting
Note over ESP: BLE off, MQTT disconnect
ESP->>CDN: HEAD / Range probe
loop paced Range
ESP->>CDN: GET bytes=off-end
CDN-->>ESP: 206 + chunk
ESP->>ESP: esp_ota_write + SHA256
end
ESP->>ESP: esp_ota_end, set_boot_partition, NVS SHA
ESP->>MQTT: reconnect + applied_rebooting
ESP->>ESP: esp_restart
Note over ESP: new slot PENDING_VERIFY
ESP->>ESP: mark_app_valid after healthy boot
Operator checklist:
- Host the
.binon HTTPS; compute SHA256. - Confirm size < inactive OTA slot.
- Publish to
{prefix}/cmd/device(QoS 1). - Watch acks:
queued→starting→applied_rebootingor error /already_applied. - After reboot, heartbeat/telemetry implies the new image was marked valid (if rollback is enabled).
Download, verify, boot
Typical worker steps (application; not esp_https_ota):
- Resolve total size.
esp_ota_get_next_update_partition— the inactive slot.total > part->size→image_too_large:{n}>{slot}.esp_ota_begin.- Range loop:
esp_ota_write; optional streaming SHA256 (PSAPSA_ALG_SHA_256). - Compare digest to payload hex (case-insensitive). Mismatch →
sha_mismatch,esp_ota_abort, boot slot unchanged. esp_ota_end(IDF image header/CRC).esp_ota_set_boot_partition.- Save SHA to NVS (
iotmer_nvs_save_creds), ackapplied_rebooting, delay,esp_restart().
Retry a few times (e.g. 3) with a short delay; abort staging between attempts.
Ack dictionary
All on cmd/ack/device, "cmd":"ota".
Success / neutral
ok | message | When |
|---|---|---|
| true | queued | Enqueued |
| true | starting | Worker about to download |
| true | already_applied | Same SHA, force false |
| true | applied_rebooting | Boot slot set; restart shortly |
Errors
ok | message | Cause |
|---|---|---|
| false | missing_url | JSON |
| false | invalid_sha256 | Not 64 hex |
| false | ota_busy | OTA queue full |
| false | no_ota_partition | Table is not dual-OTA |
| false | low_heap | Largest internal block too small |
| false | ota_size:… | HEAD/Range size failed |
| false | image_too_large:n>slot | .bin larger than slot |
| false | ota_begin:… | esp_ota_begin |
| false | no_memory | Read buffer |
| false | sha_init_failed / sha_finish_failed | Hash context |
| false | ota_chunk:… | Range/write |
| false | sha_mismatch | Stream SHA ≠ payload |
| false | ota_end:… | IDF image check |
| false | ota_boot:… | set_boot_partition |
Cloud / mobile integration
- Do not treat
queuedas done. Wait forstarting, thenapplied_rebootingor an error. Silence afterstartingis the download window. - Timeouts must cover chunked download × retries × MQTT reconnect — minutes for large images.
ota_busy: wait until reboot or an error ack before sending another OTA.already_applied: treat as success unless you pass"force": true(extra flash wear).- Always send
sha256in production. - CDN must support
Accept-Ranges/ 206 for the paced 16 KB design.
Example:
mosquitto_pub -t 'workspace/DEVICEKEY/cmd/device' -q 1 -m '{
"cmd":"ota",
"url":"https://cdn.example.com/app.bin",
"sha256":"<64-hex>",
"force":false
}'
mosquitto_sub -t 'workspace/DEVICEKEY/cmd/ack/device' -q 1
Partitions and rollback
Example 4 MB layout (adjust per module):
| Name | Type | Role |
|---|---|---|
nvs | data | Creds, firmware_applied_sha256 |
otadata | data ota | Which app slot boots |
ota_0 / ota_1 | app | Dual slots |
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y: the new image boots as ESP_OTA_IMG_PENDING_VERIFY. After MQTT/app bring-up succeeds, call esp_ota_mark_app_valid_cancel_rollback().
The bootloader must be flashed with that flag; an app-only OTA does not update the bootloader. Panic/brownout before mark-valid rolls back to the previous slot.
Choosing a path
| Use case | Path |
|---|---|
| Factory line, first image, no MQTT yet | HTTP(S) auto-OTA after provision (01_provisioning) |
| Field device, operator/console push | MQTT OTA (IOTMER_AUTO_OTA=n recommended) |
| Same SHA redeploy on the line | IOTMER_OTA_APPLY_EVEN_IF_SAME_SHA or MQTT "force": true |
Do not run both on the same boot: disable auto-OTA when MQTT OTA is the product contract, or ensure provision JSON has no firmware_url for field sessions.