OTA Updates
After the first CH341A flash, every firmware update ships wirelessly over Matter OTA, no clip. The whole pipeline is one script; don’t hand-build (the manual path has traps that ship a rolling-back image).
This page is for updating an already-custom unit. To convert a still-stock module to custom firmware in the first place, see Installing the Custom Firmware.
Deep reference: firmware/docs/10-firmware-ota-procedure.md.
The one command
firmware/scripts/ota-release.sh release --bump --flash
This runs build → package → stage → flash end to end:
| Stage | What it does |
|---|---|
build [--bump] |
sync driver/glue mirror → SDK, mandatory full clean, compile, verify serial + endpoints. --bump increments the software version first. |
package |
pad the clip image to 4 MB, create the .ota, write the sidecar JSON manifest (size + SHA-256, so they can’t drift). |
stage |
scp .ota + manifest to your Home Assistant host and restart matter-server (it only loads manifests at init). |
flash |
update_node with retries, then verify the device’s reported version changed. |
Individual stages exist too (lint, build, package, stage, flash). Run
ota-release.sh with one of them. lint is the fast offline check the git pre-commit hook runs.
Environment-specific paths/hosts live in ota-release.env (gitignored; copy from .env.example).
OTA is flaky by design: retry
update_node frequently returns error 11 “Target node did not process the update file” on the
first attempt(s): the provider is re-commissioned ephemerally each attempt and the target loses the
discovery race with the brand-new provider. It succeeds on retry. Always retry 3–5×, ~15 s
apart. The script does this for you. Verify the device’s reported softwareVersion changed;
don’t trust matter-server’s “finished” (an A/B rollback reports the old version). This is a
widespread python-matter-server behavior, not a fault in the image.
Version rule (get this wrong → the provider won’t serve)
- The new build’s version must be strictly greater than what’s running, or the provider declines to serve it. Convention: new = running + 1.
--bumpedits both the int and string inCHIPDeviceConfig.h. Lint compares againstbuilt-images/.released-version(the version last confirmed booted), so it can’t be fooled.- Don’t reuse a rolled-back version number. Matter-server and the device cache by version. If v14 rolls back, the next attempt is v15, not v14 again.
The OTA-serial trap (the script handles it)
AmebaZ2’s bootloader A/B-selects the boot slot by the image’s FWHS.header.serial, not the
Matter softwareVersion. If the serial isn’t bumped, an OTA transfers, applies, “finishes
successfully”, and the device reverts to the old version on reboot (looks like a
rollback). This cost a whole debugging session. ota-release.sh build sets
serial = SERIAL_BASE + softwareVersion and log-verifies it, so you never touch it, as long as
you use the script and don’t hand-build. The gory detail is in
firmware/docs/10-firmware-ota-procedure.md (§11).
After the OTA: new entities
If the update changed the node structure (added/removed endpoints or clusters), HA won’t show the
new entities until a re-interview. flash auto-interviews on success; if entities still lag,
reload the Matter integration in HA. See Commissioning & HA Setup.
ESP32 delta OTA
Prefer a delta (differential) patch over a full image. It is ~50× smaller (86 KB against 1.6 MB on our build), so it spends far less time on a lossy link, which is where OTA fails.
On a device built with CONFIG_ENABLE_DELTA_OTA=y the delta is mandatory. A full image
transfers, reports “finished”, and never applies. The device accepts only a patch built against
the exact binary it is running and verifies that base’s SHA first, so a patch built from the
wrong base is refused and the device stays put.
# 1. patch: base = the EXACT running .bin, new = your build
# needs detools + esptool -> run it with the IDF python env
esp_delta_ota_patch_gen.py create_patch --chip esp32 \
--base_binary <exact-running.bin> \
--new_binary build/hisense_ac_matter.bin \
--patch_file_name x.patch
# 2. wrap the PATCH (not the .bin) as the Matter image
ota_image_tool.py create -v 0xFFF1 -p 0x8000 -vn <int> -vs <semver> -da sha256 x.patch out.ota
⚠️ Archive the exact deployed .bin before you rebuild. It is the delta base, ESP-IDF builds
are not byte-reproducible, and build/ gets overwritten. Lose it and the node is USB-flash only
until you reflash it once by cable. We lost one base that way.
The manifest .json must wrap its fields in a top-level "modelVersion": {...} object. A flat
manifest raises KeyError: 'modelVersion' and crash-loops matter-server at init, taking every
node offline. Copy the shape from a manifest that already works.
Break-glass: manual HTTP OTA when Matter OTA will not finish
Matter OTA runs over BDX, which is stop-and-wait: one ~1 KB block per round trip. On a marginal
link it stalls and you get error 11. Both firmwares carry a manual fallback that pulls the image
over plain TCP instead, which has a sliding window and fast retransmit and completes where BDX
gives up. It is a deliberate escape hatch, not the primary path, and it bypasses the Matter version
negotiation entirely.
Trigger: write Identify.IdentifyTime = 88 on endpoint 1.
Serve the raw firmware_is.bin, not the .ota wrapper. The AmebaZ2 fallback streams the body
through the same writer the Matter OTA uses, which expects the ameba payload without the Matter
envelope.
Two things decide whether this works, and both are silent when wrong:
- The image’s FWHS serial must exceed the running slot’s.
ota-release.sh buildsets it. Serve a stale file and the download succeeds, the write succeeds, and the device boots the old firmware anyway. - The file server must be reachable FROM the device’s VLAN. This is the one that bites. If your IoT VLAN cannot route to your LAN, an address on the LAN will never be reached. The AmebaZ2 client is IPv4-only, so an IPv6-only address on that VLAN does not help either: give the server an IPv4 address on the VLAN the device is on.
The address is not compiled in from the repo. Set OTA_HTTP_HOST (plus OTA_HTTP_PORT,
OTA_HTTP_RESOURCE) in ota-release.env, and build injects it. Leave it unset and the firmware
keeps a placeholder that goes nowhere, which is intentional: an unconfigured build must not carry
somebody else’s server address.
Verified end to end on an AmebaZ2 unit: the device fetched over TCP, wrote the idle A/B slot and rebooted into the new version, confirmed across three fresh attribute reads.
No infra? Use the clip
The CH341A clip path is the recovery route if OTA is unavailable. package also produces the 4 MB
flash_rac-integrated-v<N>.bin for it. See Recovery & Reflash.