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:
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:
mg_ota_end()sets state toMG_OTA_TESTING, then performs bank swap or image selection- Device boots new firmware;
MG_OTA_BOOT_CHECKseesTESTING, setsFAILED, logs warning, and arms IWDG formsmilliseconds - Application initialises, gets network, runs self-tests
- Application calls
MG_OTA_STATE_SET(MG_OTA_CONFIRMED)and resets via the/api/ota/commitendpoint or directly - Device reboots;
MG_OTA_BOOT_CHECKseesCONFIRMED(0), so normal boot continues
Failure path — new firmware hangs or crashes:
mg_ota_end()setsTESTING, swaps banks, device auto-resets- Device boots new firmware;
MG_OTA_BOOT_CHECKseesTESTING, setsFAILED, and arms IWDG - Firmware hangs (or crashes, or never reaches the network)
- IWDG fires after
msmilliseconds, triggering a hardware reset - Device boots again;
MG_OTA_BOOT_CHECKseesFAILED, setsCONFIRMED, logs "rolling back", and callsMG_OTA_ROLLBACK() MG_OTA_ROLLBACK()swaps banks back and resets- 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_RT1020 – MG_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.