Firmware OTA updates

OTA looks simple until field devices start failing in awkward ways: power loss during flash, a bad image that never brings up networking, or a binary that was not signed by you. A solid updater must write flash safely, verify what was written, and keep a path back to known-good firmware. This guide shows the Mongoose OTA flow: direct flash writes, HTTP/MQTT delivery, ECDSA signing, and watchdog-based rollback.

What Mongoose firmware OTA gives you:

  • Direct-to-flash upload: no full-image RAM buffer.
  • Bank swap on MCUs with dual-bank flash.
  • Automatic rollback if the new firmware hangs, crashes, or never commits.
  • Firmware signature check with built-in ECDSA-P256 verification.
  • Authentication for update, commit, and rollback endpoints.
  • Built-in OTA backends for a large set of platforms.
  • CRA compliance: auth, signatures, rollback, documented state machine.

The low-level OTA API

The core API is three calls:

bool mg_ota_begin(size_t new_firmware_size);
bool mg_ota_write(const void *buf, size_t len);
bool mg_ota_end(void);

mg_ota_begin() receives the total firmware size, checks that it fits in the inactive flash area, and sets up the write state. On dual-bank devices, that means the inactive bank.

mg_ota_write() writes each chunk to flash. The backend handles the rough edges: it pads short tail fragments with 0xFF to the platform write granularity and erases sectors when the write pointer crosses into them. Application code does not need a separate bulk-erase pass.

mg_ota_end() verifies the written image. It recomputes the CRC32 of the full written region and compares it with the running checksum accumulated by mg_ota_write(). If signatures are enabled, it also runs ECDSA-P256 verification before doing anything irreversible. Only then does it mark the new firmware as MG_OTA_TESTING and trigger the bank swap.

Caller-side code stays small: call begin once, pass each chunk, then call end. Offsets, erase state, CRC, and signature checks stay in the OTA backend.

Streaming over HTTP

Because mg_ota_write() accepts arbitrary-sized chunks, the HTTP layer can pipe incoming body bytes directly into flash without buffering the entire image in RAM — which on a typical microcontroller would be impossible anyway.

The key is to start the OTA process at the MG_EV_HTTP_HDRS event, not MG_EV_HTTP_MSG. By the time MG_EV_HTTP_HDRS fires, Mongoose has parsed the HTTP headers and knows Content-Length, but the body has not been buffered yet. Calling mg_http_start_ota() at this point installs a streaming handler that feeds body bytes to mg_ota_write() as they arrive over the network:

static void http_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
  if (ev == MG_EV_HTTP_HDRS) {
    struct mg_http_message *hm = (struct mg_http_message *) ev_data;
    if (mg_match(hm->uri, mg_str("/api/ota/update"), NULL)) {
      mg_http_start_ota(c, hm, NULL);
    }
  } else if (ev == MG_EV_HTTP_MSG) {
    // ... other endpoints handled here
  }
}

mg_http_start_ota() accepts an optional callback for transfer completion or failure. Passing NULL uses the default handler: HTTP 200 on success, log on failure. After a successful transfer, the selected OTA backend prepares the new image for boot.

To push a firmware image from the command line:

curl http://DEVICE_IP/api/ota/update --data-binary @firmware.signed.bin

Pull-based OTA

For pull-based updates, use mg_ota_url_check():

void mg_ota_url_check(struct mg_mgr *mgr,
                      const char *current_version,
                      const char *metadata_url,
                      void (*fn)(const char *status));

It fetches JSON metadata from metadata_url, compares $.version with current_version, and downloads firmware from $.url when a newer version is available. Metadata format:

{
  "version": "1.2.3",
  "url": "http://update-server/firmware.bin",
  "size": 12345
}

The fn callback receives status strings such as "Same version", "Pulling firmware", and "OTA fail".

Streaming over MQTT

OTA can also run over MQTT when the device already keeps an outbound broker connection, or when inbound HTTP uploads are not practical. The Mongoose example uses JSON-RPC messages over MQTT to send firmware chunks with total size and offset metadata. The device maps those calls to the same OTA API: mg_ota_begin(), mg_ota_write(), and mg_ota_end().

Reference implementation: tutorials/mqtt/ota-over-mqtt/main.c

The updater is a small HTML page that connects to the same MQTT server, chunks the firmware, and publishes it to the target device. Use it as a starting point for your own update flow:

https://mongoose.ws/mqtt/

Firmware signing

Shipping a device that accepts any binary over the network is asking for trouble. Mongoose supports ECDSA-P256 firmware signatures, so the device accepts only images signed with your private release key.

Key generation and signing

The resources/sign.js script handles both steps with no npm dependencies:

node sign.js keygen            # generate private.pem, print public key #define
node sign.js sign firmware.bin # append 64-byte signature

keygen generates a P-256 key pair using Node's built-in crypto module, saves the private key as private.pem, and prints a #define ready to paste into mongoose_config.h:

#define MG_OTA_PUBLIC_KEY { \
  0x19, 0x7f, 0xed, 0x81, 0x8b, 0xb7, 0xb2, 0xa5, \
  0xde, 0xe0, 0xde, 0x04, 0x83, 0x60, 0xc8, 0x69, \
  ...                                             \
}

The public key is 64 bytes: the raw X and Y coordinates of the P-256 point, with no DER wrapper or 0x04 prefix. This is the format expected by Mongoose's bundled mg_uecc_verify().

sign reads the firmware binary, computes a SHA-256 hash, signs it with the private key using ECDSA, converts the DER-encoded signature to raw 64-byte R||S format, and appends it to the binary. The resulting .signed.bin file is the original firmware followed immediately by the 64-byte signature. The private key never leaves the build machine.

Verification in firmware

When MG_OTA_PUBLIC_KEY is defined in mongoose_config.h, mg_ota_end() runs verification before finalizing.

The last 64 bytes of the received image are the signature; everything before that is the actual firmware. The SHA-256 is computed over the firmware portion only, then verified against the appended signature using the embedded public key. If verification fails, mg_ota_end() returns false, the bank swap does not happen, and the device continues running the current firmware.

Verification runs against the copy written to inactive flash. Transport checks are useful, but the final decision is made on the stored image.

The rollback mechanism

A bad OTA must not brick the product. Rollback uses a hardware watchdog as a dead man's switch: new firmware gets a commit window. If it does not confirm itself, the watchdog resets the device and boot-time logic swaps back to the old image.

OTA state and persistent storage

The rollback state machine relies on a value that survives reset or reboot. RTC backup registers, battery-backed SRAM, FRAM, and flash are all valid choices.

For example, the STM32H563 TAMP backup register can be used by defining the access macros in mongoose_config.h:

// TAMP_BASE = 0x44007C00, BKP0R offset = 0x100 (RM0481 Table 3)
// Requires: RCC->APB3ENR |= RCC_APB3ENR_RTCAPBEN
// Requires: PWR->DBPCR |= PWR_DBPCR_DBP
#define MG_OTA_STATE_GET()   (*(volatile uint32_t *) 0x44007D00U)
#define MG_OTA_STATE_SET(v) \
  (*(volatile uint32_t *) 0x44007D00U = (uint32_t) (v))

Three state values are defined in src/ota.h:

Value Constant Meaning
0 MG_OTA_CONFIRMED Firmware known-good; normal boot
1 MG_OTA_TESTING New firmware installed; watchdog running
2 MG_OTA_FAILED Watchdog fired; roll back on next boot

The IWDG rollback timer

MG_OTA_ROLLBACK_TIMER_START(ms) arms a hardware watchdog. Once started, the watchdog should reset the device if the new firmware does not commit itself in time.

For example, on STM32H563 the Independent Watchdog can be configured like this:

#define MG_OTA_ROLLBACK_TIMER_START(ms)                                  \
  do {                                                                   \
    *(volatile uint32_t *) 0x40003000 = 0x5555U;  /* unlock */           \
    *(volatile uint32_t *) 0x40003004 = 7U;        /* /512 */            \
    *(volatile uint32_t *) 0x40003008 =                                  \
        (uint32_t) (ms) * 32U / 512U;              /* RLR */             \
    *(volatile uint32_t *) 0x40003000 = 0xCCCCU;  /* start */            \
  } while (0)

The reload value formula is ms * 32000 / 512, about ms * 62.5.

The boot-time state machine

MG_OTA_BOOT_CHECK(ms) is called once, early in main(), before any application logic runs:

int main(void) {
  hal_clock_init();
  hal_uart_init(UART_DEBUG, UART_DEBUG_TX_PIN, UART_DEBUG_RX_PIN, 115200);
  hal_rng_init();
  // ...
  MG_OTA_BOOT_CHECK(60000);   // 60-second commit window
  // normal application startup continues here
}

The macro expands to:

// boot TESTING -> set FAILED -> arm IWDG -> run firmware
//  - commit called -> set CONFIRMED -> reset -> clean boot
//  - IWDG fires -> reset -> state still FAILED -> rollback
#define MG_OTA_BOOT_CHECK(ms)                                             \
  do {                                                                    \
    if (MG_OTA_STATE_GET() == MG_OTA_FAILED) {                            \
      MG_OTA_STATE_SET(MG_OTA_CONFIRMED);                                 \
      MG_INFO(("Commit deadline expired, rolling back"));                 \
      MG_OTA_ROLLBACK();                                                  \
    } else if (MG_OTA_STATE_GET() == MG_OTA_TESTING) {                    \
      MG_OTA_STATE_SET(MG_OTA_FAILED);                                    \
      MG_INFO(("New firmware: commit within %u ms or rolls back",         \
               (unsigned) (ms)));                                         \
      MG_OTA_ROLLBACK_TIMER_START(ms);                                    \
    }                                                                     \
  } while (0)

On FAILED, the macro sets state back to CONFIRMED before calling MG_OTA_ROLLBACK(). That avoids an endless rollback loop if the swap/reset path itself fails.

Full state sequence

Happy path — new firmware works:

  1. mg_ota_end() sets state to MG_OTA_TESTING, then performs bank swap or image selection
  2. Device boots new firmware; MG_OTA_BOOT_CHECK sees TESTING, sets FAILED, logs warning, and arms IWDG for ms milliseconds
  3. Application initialises, gets network, runs self-tests
  4. Application calls MG_OTA_STATE_SET(MG_OTA_CONFIRMED) and resets via the /api/ota/commit endpoint or directly
  5. Device reboots; MG_OTA_BOOT_CHECK sees CONFIRMED (0), so normal boot continues

Failure path — new firmware hangs or crashes:

  1. mg_ota_end() sets TESTING, swaps banks, device auto-resets
  2. Device boots new firmware; MG_OTA_BOOT_CHECK sees TESTING, sets FAILED, and arms IWDG
  3. Firmware hangs (or crashes, or never reaches the network)
  4. IWDG fires after ms milliseconds, triggering a hardware reset
  5. Device boots again; MG_OTA_BOOT_CHECK sees FAILED, sets CONFIRMED, logs "rolling back", and calls MG_OTA_ROLLBACK()
  6. MG_OTA_ROLLBACK() swaps banks back and resets
  7. Device boots original firmware; state is CONFIRMED, normal operation resumes

An important implementation detail is that MG_OTA_STATE_SET(MG_OTA_TESTING) happens before swap_fn(), not after. Some platforms reset during image selection, so code placed after swap_fn() may never execute. Writing the state first ensures the TESTING marker reaches persistent storage before any reset.

Committing from an HTTP endpoint

This snippet exposes /api/ota/commit and /api/ota/rollback:

} else if (ev == MG_EV_HTTP_MSG) {
  struct mg_http_message *hm = (struct mg_http_message *) ev_data;
  ...
  } else if (mg_match(hm->uri, mg_str("/api/ota/commit"), NULL)) {
    c->data[0] = 1;
    mg_http_reply(c, 200, "", "ok\n");
  } else if (mg_match(hm->uri, mg_str("/api/ota/rollback"), NULL)) {
    c->data[0] = 2;
    mg_http_reply(c, 200, "", "ok\n");
  }
} else if (ev == MG_EV_CLOSE && c->data[0] == 1) {
  MG_OTA_STATE_SET(MG_OTA_CONFIRMED);
  NVIC_SystemReset();
} else if (ev == MG_EV_CLOSE && c->data[0] == 2) {
  MG_OTA_STATE_SET(MG_OTA_CONFIRMED);
  MG_OTA_ROLLBACK();
}

The c->data[0] = XXX; trick gives the HTTP response time to reach the client before the network stack disappears. Both paths set state to CONFIRMED first: commit because the new firmware is good, rollback because the swap back must not look like another failed boot.

Platform support

Mongoose includes built-in OTA implementations for a range of targets:

MG_OTA value Platform
MG_OTA_STM32H5 STM32H5 series (dual-bank, auto-reset)
MG_OTA_STM32H7 STM32H7 series (dual-bank)
MG_OTA_STM32H7_DUAL_CORE STM32H7 dual-core
MG_OTA_STM32F STM32F2/F4/F7 (single-bank sectors)
MG_OTA_ESP32 ESP32 family (ESP-IDF OTA partitions)
MG_OTA_PICOSDK RP2040 / RP2350 via Pico SDK hardware_flash
MG_OTA_RT1020MG_OTA_RT1170 NXP i.MX RT series
MG_OTA_MCXN NXP MCXN947
MG_OTA_RW612 NXP FRDM-RW612
MG_OTA_U2A Renesas U2A16 / U2A8 / U2A6
MG_OTA_CH32V307 WCH CH32V307
MG_OTA_CUSTOM User-supplied implementation

Each built-in target provides mg_ota_begin(), mg_ota_write(), and mg_ota_end() for that platform's flash geometry, write granularity, and bank swap mechanism. Shared code in src/flash.c handles CRC and signatures, then calls platform write_fn and swap_fn callbacks.

For custom platforms, define MG_OTA MG_OTA_CUSTOM and implement the three functions directly. The MG_OTA_STATE_GET, MG_OTA_STATE_SET, MG_OTA_ROLLBACK, and MG_OTA_ROLLBACK_TIMER_START macros are all independently overridable in mongoose_config.h.

Configuration summary

For a complete signed-OTA rollback example, see nucleo-h563zi-minimal. It uses CMSIS headers and a small hand-written hal.h for GPIO, UART, and Ethernet setup. The OTA pieces are not tied to that build system; move the same macros and handlers to your board support code.