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.
- name
firmware-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
- ESP32 Wi-Fi, NVS and partition basics without OTA — use esp32-firmware
- General release notes and versioning for software — use release-management
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.
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
| Platform | Default | Notes |
|---|---|---|
| Zephyr or bare-metal Cortex-M | MCUboot, swap mode, two slots | Test-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 enabled | esp_https_ota(); signed apps via Secure Boot v2 (eFuse, irreversible) |
| Embedded Linux | A/B rootfs with RAUC, SWUpdate, or Mender | Bootloader (U-Boot) boot counter + fallback; data partition separate |
| Flash too small for two slots | Single app slot + small recovery/loader image, or external flash as staging | Overwrite-only update; must never erase the running image before the new one is verified |
| Bandwidth-constrained (cellular, LoRa) | Delta updates on top of A/B | Patch 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_versionfrom 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
| Stage | Cohort | Minimum soak | Proceed only if |
|---|---|---|---|
| 0 | Internal/dogfood devices | 24-48 h | Zero rollbacks, no new crash signatures |
| 1 | 1% of fleet (hash of device ID) | 24 h | Rollback rate < 0.5%, reset rate not above baseline, check-in rate >= 99% of expected |
| 2 | 10% | 48 h | Same gates; support tickets flat |
| 3 | 50% | 48 h | Same gates |
| 4 | 100% | ongoing | Monitor 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)
| Test | Expected |
|---|---|
| Power cut during download (several offsets) | Boots old image; resumes or restarts download |
| Power cut during swap/copy | Bootloader completes or reverts; device boots |
| Image signed with wrong/dev key | Rejected by bootloader, old image keeps running |
| Truncated or corrupted image | Rejected before marking pending |
| Older version (downgrade/replay) | Rejected |
| Wrong hardware revision | Rejected by client before download |
| Self-test fails or hangs | Reverts to previous image, reports rollback |
| Two updates back to back | Second 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 flashor debugger flashing writesota_0and may leaveotadatapointing at the other slot; eraseotadata(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
- Failure test matrix passes on real hardware (not only an emulator), including at least 20 randomized power cuts during download and apply.
ota_manifest.py checkpasses for the exact artifact being published.- A deliberately broken build (self-test forced to fail) rolls back and reports it.
- 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
#!/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.