Embedded & IoT No. 460Editor's pick

Firmware OTA Updates skill for Claude Code

Updates that survive power cuts and bad builds. Claude designs OTA so a device always boots something that works: A/B slots, bootloader signature checks, anti-downgrade rules, and a self-test that must pass before the image is confirmed. It writes the confirm and rollback code for ESP-IDF and MCUboot, generates release manifests, runs a power-cut test matrix, and plans staged rollouts with numeric halt gates.

  • namefirmware-ota
  • size212 lines · ≈2,550 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and implements over-the-air firmware updates that cannot brick devices, using A/B slots, MCUboot or ESP-IDF OTA, signed images, self-test before confirm, automatic rollback, anti-downgrade rules, resumable HTTPS downloads, release manifests, and staged fleet rollouts with halt criteria. Use when adding OTA to an MCU or embedded Linux product, choosing between MCUboot, ESP-IDF OTA, Mender, RAUC, or SWUpdate, signing firmware, planning a rollout, or investigating devices stuck after an update.

Try asking

  • Add OTA with automatic rollback to our ESP32 firmware, it currently has a single app partition
  • Set up MCUboot image signing in our Zephyr sysbuild and keep the key out of the repo
  • Write a staged rollout plan for firmware 2.0 across 40,000 devices

Use it when

  • Adding over-the-air updates to an MCU or embedded Linux product
  • Choosing MCUboot, ESP-IDF OTA, Mender, RAUC or SWUpdate
  • Signing firmware images and preventing downgrades
  • Planning or debugging a fleet rollout that bricked or stranded devices

Skip it when

How to check the result

The power-cut and bad-image test matrix passes on real hardware, a forced self-test failure rolls back and reports it, and the manifest check passes for the published artifact.

The common mistake it prevents

Confirming the new image at the very start of main, so a build that boots but cannot reach the network is permanently stranded with no rollback.

~/.claude/skills/firmware-ota/SKILL.md

--- name: firmware-ota description: Designs and implements over-the-air firmware updates that cannot brick devices, using A/B slots, MCUboot or ESP-IDF OTA, signed images,… license: MIT ---

Done well means: power can fail at any instant during an update and the device still boots a working image, the bootloader refuses unsigned or older images, a new image proves itself before it is confirmed, and releases reach the fleet in stages with numeric halt criteria.

Ground rules

  • Pushing a release to production devices requires explicit user confirmation, and so does any 100% rollout, forced update, or bootloader update. State the cohort size and rollback path when asking.
  • Irreversible security steps require explicit confirmation: burning eFuses for ESP32 Secure Boot v2 or anti-rollback counters, writing OTP key hashes, locking debug ports. A wrong key bricks devices permanently.
  • Private signing keys never go in the repo, the firmware image, or plain CI variables. Keep them in an HSM or cloud KMS and sign in a restricted pipeline step. Separate dev and production keys; dev-signed images must not boot on production hardware.
  • Keep the first-stage bootloader immutable in the field unless there is no other way; a failed bootloader update has no recovery path.
  • A manifest hash is integrity, not authenticity. The bootloader must verify a cryptographic signature over the image.

Workflow

- [ ] 1. Inventory: flash size, current partition layout, bootloader, connectivity, power source
- [ ] 2. Choose the update architecture (table below) and the signing scheme
- [ ] 3. Define the image trailer/manifest: version, build number, hw compatibility, hash, min_version
- [ ] 4. Implement download: HTTPS, resumable, verified before marking the slot pending
- [ ] 5. Implement self-test and confirm; reboot into rollback if it fails or times out
- [ ] 6. Run the failure test matrix (power cuts, bad signature, downgrade, self-test fail)
- [ ] 7. Plan the staged rollout with halt thresholds; get confirmation before each stage

Choosing an architecture

PlatformDefaultNotes
Zephyr or bare-metal Cortex-MMCUboot, swap mode, two slotsTest-then-confirm built in; sign with imgtool; transport via mcumgr/SMP or your own client
ESP32 (ESP-IDF)Two OTA app partitions + otadata, app rollback enabledesp_https_ota(); signed apps via Secure Boot v2 (eFuse, irreversible)
Embedded LinuxA/B rootfs with RAUC, SWUpdate, or MenderBootloader (U-Boot) boot counter + fallback; data partition separate
Flash too small for two slotsSingle app slot + small recovery/loader image, or external flash as stagingOverwrite-only update; must never erase the running image before the new one is verified
Bandwidth-constrained (cellular, LoRa)Delta updates on top of A/BPatch must be applied against a verified base; fall back to full image

MCUboot modes: swap (scratch/move/offset) keeps the old image for revert; overwrite-only saves time but cannot revert; direct-XIP boots either slot in place (images built per slot). Pick swap unless flash wear or size forbids it.

Signing

MCUboot with Zephyr sysbuild:

# one-time: generate a key in a secure location (never commit it)
imgtool keygen -k ~/secure/fw-prod-ecdsa-p256.pem -t ecdsa-p256
# sysbuild.conf in the app
#   SB_CONFIG_BOOTLOADER_MCUBOOT=y
#   SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
#   SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="/path/from/secure/mount/fw-prod-ecdsa-p256.pem"
west build -b <board> app --sysbuild
# signed image: build/app/zephyr/zephyr.signed.bin

Manual signing for a raw binary:

imgtool sign --key fw-prod-ecdsa-p256.pem --header-size 0x200 --align 4 \
  --version 1.4.0+412 --slot-size 0x60000 --pad-header app.bin app.signed.bin
imgtool verify --key fw-prod-ecdsa-p256.pem app.signed.bin

The --slot-size and --header-size must match the bootloader’s partition layout, or the bootloader rejects the image.

ESP-IDF: CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT verifies signatures on OTA without burning secure-boot eFuses (weaker: the bootloader itself is not verified). Full Secure Boot v2 plus flash encryption is the production choice but is irreversible; plan it with the user and test on sacrificial devices first.

Self-test and confirm

Never confirm the image at the first line of main. Confirm after the new firmware has proven the things an update must not break: it boots, its watchdog is fed, it reaches the network, and it can reach the update server (so a further fix can still be delivered).

ESP-IDF (with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y):

const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
if (esp_ota_get_state_partition(running, &state) == ESP_OK &&
    state == ESP_OTA_IMG_PENDING_VERIFY) {
    if (self_test_passes()) {                    // network up, server reachable, sensors OK
        esp_ota_mark_app_valid_cancel_rollback();
    } else {
        esp_ota_mark_app_invalid_rollback_and_reboot();
    }
}

Zephyr + MCUboot:

#include <zephyr/dfu/mcuboot.h>

if (!boot_is_img_confirmed()) {
    if (self_test_passes()) {
        boot_write_img_confirmed();              // otherwise the next reset reverts
    } else {
        sys_reboot(SYS_REBOOT_COLD);             // MCUboot swaps back
    }
}
  • Bound the self-test with a timeout (e.g. 2-5 minutes) and a watchdog, so a hang also ends in a reset and revert.
  • Report the outcome (confirmed, rolled back, reason) to the backend on the next connection; rollout gates depend on it.

Download and apply

  • HTTPS with certificate validation (CA bundle or pinned intermediate); never plain HTTP even though the image is signed (privacy, and denial of service via junk images).
  • Resumable with HTTP Range requests; write to the inactive slot in chunks; verify size and SHA-256 against the manifest, then let the bootloader verify the signature.
  • Preconditions before starting: battery above a threshold (e.g. 30%) or on external power, not in a safety-critical operation, enough free time window.
  • Check hardware revision and min_version from the manifest before downloading; ship stepping-stone releases when a migration requires an intermediate version.
  • Anti-downgrade: reject versions not newer than the running one in the client, and use the bootloader’s downgrade prevention / security counter so a replayed old signed image with known bugs cannot be installed.

Release manifest helper (stdlib Python):

python3 scripts/ota_manifest.py build --image build/app.signed.bin --version 1.4.0 --build 412 \
  --hw rev-b --hw rev-c --slot-size 0x60000 --min-version 1.2.0 \
  --url https://fw.example.com/app/1.4.0.bin --out manifest.json
python3 scripts/ota_manifest.py check --manifest manifest.json --image build/app.signed.bin \
  --current 1.3.2 --hw rev-b

build hashes the image, warns above 90% slot usage and fails if it does not fit; check verifies size, SHA-256, https URL, hardware compatibility, newer-than-current, and min_version. Exit 1 on any failed check, 2 on bad input.

Data and settings across versions

  • Version every persisted structure (NVS keys, settings files, databases).
  • New firmware must read old data. Old firmware must survive data written by new firmware, because rollback can happen after the new version ran: either keep migrations additive (new keys, never rewrite old ones) or migrate only after confirm and keep the old copy.
  • Never change partition tables in an OTA unless the bootloader supports it; it is the most common way to brick a fleet.

Staged rollout

StageCohortMinimum soakProceed only if
0Internal/dogfood devices24-48 hZero rollbacks, no new crash signatures
11% of fleet (hash of device ID)24 hRollback rate < 0.5%, reset rate not above baseline, check-in rate >= 99% of expected
210%48 hSame gates; support tickets flat
350%48 hSame gates
4100%ongoingMonitor for a week
  • Automatic halt: pause the rollout when any gate is breached; never auto-advance past stage 1 without a human confirming.
  • Devices that fail to check in after updating are the most important signal; a bricked device cannot report a rollback.
  • Keep the previous release published so devices can be pointed back to it.

Failure test matrix (run before every release that changes the update path)

TestExpected
Power cut during download (several offsets)Boots old image; resumes or restarts download
Power cut during swap/copyBootloader completes or reverts; device boots
Image signed with wrong/dev keyRejected by bootloader, old image keeps running
Truncated or corrupted imageRejected before marking pending
Older version (downgrade/replay)Rejected
Wrong hardware revisionRejected by client before download
Self-test fails or hangsReverts to previous image, reports rollback
Two updates back to backSecond waits until first is confirmed

Gotchas

  • Confirming immediately at boot makes rollback useless; a firmware that boots but cannot reach the network is stranded forever.
  • MCUboot test swap reverts on the next reset if not confirmed; a watchdog reset during a slow self-test can unexpectedly revert a good image. Size the timeout accordingly.
  • idf.py flash or debugger flashing writes ota_0 and may leave otadata pointing at the other slot; erase otadata (idf.py erase-otadata) during development when behavior looks inconsistent, after confirming it is a dev board.
  • Clock not set: TLS certificate validation fails on devices without RTC until SNTP syncs; sync time before downloading.

Verification

  1. Failure test matrix passes on real hardware (not only an emulator), including at least 20 randomized power cuts during download and apply.
  2. ota_manifest.py check passes for the exact artifact being published.
  3. A deliberately broken build (self-test forced to fail) rolls back and reports it.
  4. Rollout plan written with cohorts, soak times, halt thresholds, and the user’s explicit go-ahead recorded before stage 1.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/ota_manifest.py 159 lines · 5.9 KB
#!/usr/bin/env python3
"""Build and check OTA release manifests (stdlib only).

  build: hash an image, check it fits the slot, emit manifest JSON
    python3 ota_manifest.py build --image fw.bin --version 1.4.0 --build 412 \
        --hw rev-b --hw rev-c --slot-size 0x60000 --url https://cdn.example.com/fw/1.4.0.bin \
        --out manifest.json

  check: verify an image against a manifest before publishing or after download
    python3 ota_manifest.py check --manifest manifest.json --image fw.bin \
        --current 1.3.2 --hw rev-b

Exit status: 0 = OK, 1 = a check failed, 2 = bad input.
The manifest is not a signature. Sign the image itself (imgtool, Secure Boot v2,
or your platform's signer) and keep this file as release metadata.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from pathlib import Path

SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$")
HEADROOM = 0.10  # warn when an image uses more than 90% of its slot


def parse_version(v: str) -> tuple:
    m = SEMVER.match(v)
    if not m:
        raise ValueError(f"version '{v}' is not semver MAJOR.MINOR.PATCH[-pre]")
    major, minor, patch, pre = m.groups()
    # a release sorts after its pre-releases
    return (int(major), int(minor), int(patch), 0 if pre else 1, pre or "")


def parse_size(s: str) -> int:
    s = s.strip().lower()
    mult = 1
    if s.endswith("k"):
        mult, s = 1024, s[:-1]
    elif s.endswith("m"):
        mult, s = 1024 * 1024, s[:-1]
    return int(s, 0) * mult


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def cmd_build(a) -> int:
    img = Path(a.image)
    if not img.is_file():
        print(f"error: image {img} not found", file=sys.stderr)
        return 2
    parse_version(a.version)
    size = img.stat().st_size
    slot = parse_size(a.slot_size)
    status = 0
    if size > slot:
        print(f"FAIL: image is {size} bytes, slot holds {slot} bytes", file=sys.stderr)
        status = 1
    elif size > slot * (1 - HEADROOM):
        print(f"WARN: image uses {size / slot:.0%} of the slot; plan a partition change before the next feature", file=sys.stderr)
    manifest = {
        "version": a.version,
        "build": a.build,
        "hardware": sorted(set(a.hw)),
        "size": size,
        "sha256": sha256(img),
        "url": a.url,
        "min_version": a.min_version,
        "rollout_percent": a.rollout,
    }
    text = json.dumps(manifest, indent=2) + "\n"
    if a.out:
        Path(a.out).write_text(text)
        print(f"wrote {a.out} ({size} bytes, {size / slot:.0%} of slot)")
    else:
        sys.stdout.write(text)
    return status


def cmd_check(a) -> int:
    try:
        m = json.loads(Path(a.manifest).read_text())
    except (OSError, json.JSONDecodeError) as e:
        print(f"error: cannot read manifest: {e}", file=sys.stderr)
        return 2
    missing = [k for k in ("version", "hardware", "size", "sha256", "url") if k not in m]
    if missing:
        print(f"FAIL: manifest missing fields: {', '.join(missing)}")
        return 1
    failures = []
    img = Path(a.image)
    if not img.is_file():
        print(f"error: image {img} not found", file=sys.stderr)
        return 2
    if img.stat().st_size != m["size"]:
        failures.append(f"size {img.stat().st_size} != manifest {m['size']}")
    digest = sha256(img)
    if digest != m["sha256"]:
        failures.append(f"sha256 mismatch (image {digest[:16]}..., manifest {m['sha256'][:16]}...)")
    if not str(m["url"]).startswith("https://"):
        failures.append("url is not https")
    if a.hw and a.hw not in m["hardware"]:
        failures.append(f"hardware '{a.hw}' not in {m['hardware']}")
    if a.current:
        try:
            new, cur = parse_version(m["version"]), parse_version(a.current)
        except ValueError as e:
            print(f"error: {e}", file=sys.stderr)
            return 2
        if new <= cur:
            failures.append(f"version {m['version']} is not newer than current {a.current} (downgrade or replay)")
        if m.get("min_version") and cur < parse_version(m["min_version"]):
            failures.append(f"current {a.current} is below min_version {m['min_version']}; ship a stepping-stone release first")
    if failures:
        for f in failures:
            print(f"FAIL: {f}")
        return 1
    print(f"OK: {img.name} matches manifest {m['version']} ({m['size']} bytes, sha256 {digest[:16]}...)")
    return 0


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build", help="create a manifest for an image")
    b.add_argument("--image", required=True)
    b.add_argument("--version", required=True)
    b.add_argument("--build", type=int, default=0, help="monotonic build number")
    b.add_argument("--hw", action="append", required=True, help="compatible hardware revision (repeatable)")
    b.add_argument("--slot-size", required=True, help="app slot size, e.g. 0x60000, 1536k, 3m")
    b.add_argument("--url", required=True)
    b.add_argument("--min-version", default=None, help="oldest version allowed to install this directly")
    b.add_argument("--rollout", type=int, default=0, help="initial rollout percent (default 0 = staged manually)")
    b.add_argument("--out")
    c = sub.add_parser("check", help="verify an image against a manifest")
    c.add_argument("--manifest", required=True)
    c.add_argument("--image", required=True)
    c.add_argument("--current", help="version currently on the device")
    c.add_argument("--hw", help="device hardware revision")
    a = p.parse_args()
    try:
        return cmd_build(a) if a.cmd == "build" else cmd_check(a)
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc