Skip to main content

OTA

IOTMER firmware updates use two trigger paths and one download plane.

PathTriggerWho implements itBinary transport
HTTP(S) OTAProvision JSON (firmware_url + SHA256)SDK (iotmer_ota.c, IOTMER_AUTO_OTA)HTTPS (esp_https_ota)
MQTT OTAMQTT cmd / cmd/device with "cmd":"ota"Application workerHTTP(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) plus otadata. iotmer doctor warns when the table is not OTA-capable.
  • Prefer HTTPS URLs. TLS uses iotmer_tls_get_ca_cert_pem() when pinned, otherwise esp_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_end only 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 key fw_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

ConditionResult
Empty firmware_urlSkip (log). Checksum without URL is a warning (truncated URL / JSON).
Empty firmware_checksum_sha256Skip — SDK requires SHA for auto-OTA.
SHA equals NVS firmware_applied_sha256Skip, unless force (below).
URL + SHA present and SHA differsDownload, verify, save SHA, esp_restart().

Force re-download (same SHA):

MethodWhen
IOTMER_OTA_APPLY_EVEN_IF_SAME_SHA=yFactory images
HTTPS provision on the same bootSDK 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:

  1. Locates the next update partition (esp_ota_get_next_update_partition)
  2. Reads the written bytes back
  3. Computes SHA256 (PSA Crypto on IDF ≥ 6, else mbedTLS)
  4. 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)
QoS1
Retain0
BLEDo not start OTA from BLE
{
"cmd": "ota",
"url": "https://example.com/firmware.bin",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"force": false
}
FieldRequiredNotes
cmdYes"ota"
urlYes*Firmware .bin HTTP(S) URL. Cap ~511 characters.
firmware_urlNoAlias if url is absent (same name as provision).
sha256Recommended64 hex. If missing, skip integrity check (log it).
forceNotrue 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
QoS1
Retain0
{
"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)

SchemeClient
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()AckMeaning
ESP_OKok=true, message=queuedAccepted; download not started
ESP_ERR_INVALID_STATEok=false, message=ota_busyQueue full (OTA already pending or running)
ESP_ERR_INVALID_ARGmissing_url / invalid_sha256Handler validation
ESP_ERR_NO_MEMesp_err_to_nameQueue/task alloc failed

Worker (xQueueReceive):

  1. Same SHA as NVS and force==falseok=true, message=already_applied (no download).
  2. No OTA partition → ok=false, message=no_ota_partition.
  3. Else first ok=true, message=starting while MQTT is still up.
  4. 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:

  1. Host the .bin on HTTPS; compute SHA256.
  2. Confirm size < inactive OTA slot.
  3. Publish to {prefix}/cmd/device (QoS 1).
  4. Watch acks: queuedstartingapplied_rebooting or error / already_applied.
  5. 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):

  1. Resolve total size.
  2. esp_ota_get_next_update_partition — the inactive slot.
  3. total > part->sizeimage_too_large:{n}>{slot}.
  4. esp_ota_begin.
  5. Range loop: esp_ota_write; optional streaming SHA256 (PSA PSA_ALG_SHA_256).
  6. Compare digest to payload hex (case-insensitive). Mismatch → sha_mismatch, esp_ota_abort, boot slot unchanged.
  7. esp_ota_end (IDF image header/CRC).
  8. esp_ota_set_boot_partition.
  9. Save SHA to NVS (iotmer_nvs_save_creds), ack applied_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

okmessageWhen
truequeuedEnqueued
truestartingWorker about to download
truealready_appliedSame SHA, force false
trueapplied_rebootingBoot slot set; restart shortly

Errors

okmessageCause
falsemissing_urlJSON
falseinvalid_sha256Not 64 hex
falseota_busyOTA queue full
falseno_ota_partitionTable is not dual-OTA
falselow_heapLargest internal block too small
falseota_size:…HEAD/Range size failed
falseimage_too_large:n>slot.bin larger than slot
falseota_begin:…esp_ota_begin
falseno_memoryRead buffer
falsesha_init_failed / sha_finish_failedHash context
falseota_chunk:…Range/write
falsesha_mismatchStream SHA ≠ payload
falseota_end:…IDF image check
falseota_boot:…set_boot_partition

Cloud / mobile integration

  1. Do not treat queued as done. Wait for starting, then applied_rebooting or an error. Silence after starting is the download window.
  2. Timeouts must cover chunked download × retries × MQTT reconnect — minutes for large images.
  3. ota_busy: wait until reboot or an error ack before sending another OTA.
  4. already_applied: treat as success unless you pass "force": true (extra flash wear).
  5. Always send sha256 in production.
  6. 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):

NameTypeRole
nvsdataCreds, firmware_applied_sha256
otadatadata otaWhich app slot boots
ota_0 / ota_1appDual 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 casePath
Factory line, first image, no MQTT yetHTTP(S) auto-OTA after provision (01_provisioning)
Field device, operator/console pushMQTT OTA (IOTMER_AUTO_OTA=n recommended)
Same SHA redeploy on the lineIOTMER_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.