GitHub

Updating the firmware the tool installs

A converted plug ends on official Tasmota, OpenBeken or this project’s ESPHome. Tasmota and ESPHome follow their projects’ releases, a little behind: the repository’s firmware-update workflow takes each once it has settled, tests it and releases it, by itself (below). Doing it by hand takes the same steps (after that).

What follows a release, and what does not

Firmware On the plug Follows Pinned in
Official Tasmota: tasmota32c3.bin, tasmota32c3-safeboot.bin (and tasmota32c3.factory.bin, for its partition table) for good every Tasmota release firmware.json
ESPHome for the S60 (artifacts/esphome-sonoff-s60-imprintable-*.bin) for good every ESPHome release firmware.json
ESPHome’s bootloader (artifacts/esphome-sonoff-s60-bootloader-*.bin) for good only when it has to change firmware.json
The mover (artifacts/autoflash-mover-*.bin) a minute or two only when its config changes firmware.json
OpenBeken for the S61 (artifacts/openbeken-sonoff-s61-*.rbl) for good by hand: OpenBeken has no releases to settle on firmware.json
ESPHome for the S61 (artifacts/esphome-sonoff-s61-*.rbl) for good every ESPHome release, by hand so far firmware.json
The Berry Tasmota, the recovery Safeboot, the settings template, the bridge minutes no artifacts/README.md says where, and how each is changed

A new pin reaches plugs already converted. Running the tool on a plug with an earlier release of its firmware offers this one as an update, and the plug takes it, so treat a pin bump as a change to plugs in use as well as to new conversions (Changing firmware later). A plug adopted into Home Assistant keeps to the release it names, and the tool leaves it alone: firmware.json records when each published ESPHome image was built (built, earlier_builds), and the tool updates only a plug that reports one of those times.

The official Tasmota files are not kept in this repository: each run downloads them from the release’s own page on GitHub and checks them against firmware.json. The ESPHome builds are kept in it: the run writes the plug’s Wi-Fi into a copy of each.

The interim firmware - everything a plug runs only part way through a conversion - stays as it is. Each has been through every fault the suite puts in its way, a change to one means all of that again, and none of it is left on a plug. The Tasmota they are made from is INTERIM_TASMOTA_COMMIT in tools/espressif/safeboot/safeboot_migration.py, which need not be the official release’s: the recovery Safeboot saves the Wi-Fi in its release’s settings record, and the official release takes that record and moves it on to its own layout, as Tasmota does on any upgrade - as long as CFG_HOLDER is the same in both, which the update checks. The S61’s interim firmware is its OpenBeken image itself, with a marker sent after it, so it changes with every OpenBeken update (below).

The bootloader is written in about a second, and a power cut in that second leaves a plug that has to be opened - the only step of a conversion that can. So a new ESPHome release brings a new app, and the published bootloader stays, unless the new one fixes something an S60 needs (an ESP32-C3 with no secure boot or flash encryption); the bootloader of ESP-IDF 5.x starts the apps of later 5.x releases. Replacing it is a decision of its own: --new-bootloader, then the whole fault suite and real plugs.

By itself

Every Monday .github/workflows/firmware-update.yml asks which release of each project has settled (python3 tools/repo/update_firmware.py due). A release has settled when it is

  1. a stable release - never a beta, a pre-release or a development build;
  2. the newest of its series (Tasmota’s 16.0.x, ESPHome’s month, 2026.10.x): no later point release; and
  3. at least 7 days old, and either a newer series has begun or it is at least 14 days old (SETTLE_MIN_DAYS, SETTLE_DAYS).

A fix for a bad release has come within days or not at all - Tasmota 14.4.0 was followed by 14.4.1 in four days, 15.0.0 by 15.0.1 in one, each .0 withdrawn - so this lets it come first. For Tasmota, a release every two months or so, it means 14 quiet days; for ESPHome, a series a month, the month before’s last point release once the new month’s .0 is out: always a month behind, on a series that has had its fixes. A release listed under hold in firmware.json ("hold": {"tasmota": ["16.1.0"]}) is never taken.

The workflow makes what is due into a pull request: the update (steps 1 and 2 below, its docs and changelog line written, the version raised a minor version), then the unit tests and the final-firmware emulator test (step 4). Nothing is pushed if the leak scan finds anything in it. The pull request says which releases, what each check found, and the tests’ results; the workflow then starts the lint, leak-scan and emulator-check workflows on its branch, as a pull request opened with a workflow’s own token starts none itself, so the checks main requires are there. One whose tests passed merges itself 7 days after it was opened, which is the release, unless it is labelled hold; one whose tests failed, or a check main requires, is labelled tests-failed and never merged by the workflow. A pull request is merged only if main has not moved on since it was made and tested; otherwise it is closed, and the next week’s run makes it again.

The same run checks the pinned Tasmota release is still published - each conversion downloads its files - and opens an issue if it has gone, taking its series’ newest release at once where there is one. It also opens one once nothing has reached main for 50 days: GitHub switches a public repository’s schedules off after 60 days with no activity, this one with them.

By hand, from the Actions tab: tasmota or esphome takes a given release whether settled or not, and dry_run makes and tests the update and opens nothing. python3 tools/repo/update_firmware.py check says what is pinned and what is latest.

By hand

Work on a branch from main. Each step below is one commit.

1. Tasmota

python3 tools/repo/update_firmware.py tasmota            # the latest release
python3 tools/repo/update_firmware.py tasmota 16.0.0     # or that one

It reads the release from GitHub (refusing a draft or pre-release) and its tag’s commit, downloads its three ESP32-C3 files, and checks:

Then it writes the release, its commit and each file’s size and SHA-256 into firmware.json. --dry-run checks without writing.

Where it stops:

2. ESPHome

python3 tools/repo/update_firmware.py esphome            # the latest release
python3 tools/repo/update_firmware.py esphome 2026.10.0 --image-version 1.3.0

A change to devices/sonoff-s60/esphome.yaml is published the same way, naming the release already pinned, so that only the config changes.

It needs Docker: the image is built in ESPHome’s own image for that release (ghcr.io/esphome/esphome:<release>), pinned by its digest, so the build does not depend on what is installed on the computer building it. It checks, then builds:

Then it publishes the image in artifacts/ (removing the one before), and updates its line in SHA256SUMS, the config’s version and firmware.json. --mover builds and publishes the mover the same way, only for a change to its config. --dry-run builds and checks, and publishes nothing. ESP-IDF and its tools are downloaded once, to captures/esphome-build/.

An ESPHome build is not byte for byte repeatable - it holds the time it was built and where - so what pins a build is the Docker image it was made in, in firmware.json. The bootloader is repeatable: built again with the release that made the published one, it comes out the same.

3. OpenBeken and ESPHome for the S61

A Beken plug ends on OpenBeken or on ESPHome built on LibreTiny, each an rt_ota image the plug’s stock bootloader applies from its download slot (devices/sonoff-s61/); the images are pinned under beken in firmware.json, by plug.

A change to what the OpenBeken driver does on first boot reaches only plugs converted after it: an updated plug keeps its settings, so the import does not run again. A fix that updated plugs need goes in what the driver does on every start (openbeken.md).

python3 tools/repo/update_firmware.py openbeken                       # the pinned OpenBeken commit
python3 tools/repo/update_firmware.py openbeken --commit <sha> --image-version 1.1.0
python3 tools/repo/update_firmware.py esphome 2026.10.0 --plug sonoff-s61

openbeken clones OpenBK7231T_App at the commit pinned (or --commit; with none pinned, main’s head) and its SDK, applies this project’s patches (tools/beken/openbeken/: the 4 MB table, offered upstream, and the hook before an OTA’s restart the driver uses), adds this project’s OpenBeken driver (tools/beken/openbeken/drv_autoflash.c and drv_autoflash.h) and the plug’s table for it (devices/sonoff-s61/drv_autoflash_sonoff_s61.c) to the build and to start-up as openbeken.md says, and builds OpenBK7238 with FLASH_SIZE=4M in this project’s build image (tools/beken/openbeken/build.Dockerfile, Ubuntu 24.04: the SDK’s own toolchain wants a newer GLIBC than OpenBeken’s image has). It checks the image is an rt_ota app of the version asked, fits the download slot, and holds each command the driver registers; then it publishes it, recording the OpenBeken and SDK commits, the hashes of the driver, the table and the patches, and the build image. OpenBeken has no releases, so nothing takes it by itself: update it by hand, on a commit that has been out a while. The version is this project’s for the image (--image-version, the next minor by default), not OpenBeken’s.

esphome --plug sonoff-s61 builds devices/sonoff-s61/esphome.yaml in ESPHome’s image for the release, as the S60’s is built, and takes image_bk7238_app.ota.rbl: checked as an rt_ota app that fits the slot, says its version, project and ESPHome release, and has the Wi-Fi action in it, with the build’s download partition where the stock bootloader looks. Its first boot reads the plug’s Wi-Fi and calibration from the stock store, so the image holds no placeholders and nothing is written into it. The config’s version is raised and the image published as the S60’s is. The docs for either are written by hand (below).

4. The docs

Each update writes what it changed, and tests/repo/test_firmware_pins.py fails while any of it is missing:

For a change to the config, built with the release already pinned, they say the config changed, and what the change does is left to write by hand in CHANGELOG.md and artifacts/README.md.

Each passage is found and rewritten, and if one is not found nothing is changed: --no-docs leaves them all to be written by hand, as a change to the mover or the bootloader (--mover, --new-bootloader) needs anyway. The README’s badges read firmware.json on main, and change by themselves.

5. Test

Unit tests, in a checkout with no emulator state linked in:

python3 tests/run.py

The emulator, from just before the final firmware: nothing before it changes, and it was tested by the fault suite when it last did. Two public plugs (emulator.md) - one after E3 on the way to Tasmota, one after D5 on the way to ESPHome - are taken to their final firmware (F2 to H1, and I1 and I2), and the plugs that makes are then taken each to the other (L1 to I2, and X1 to H1): every step that writes, starts, checks or runs code on the final firmware, in four or five minutes on two emulators - one for each way, each going on to its second run as soon as its first is done. The plugs hold nothing of Sonoff’s, so no cloud is needed, real or stub, nor an account:

python -I tools/espressif/emulator/public_plugs.py test

All four must convert. The repository’s emulator-check workflow runs the same, with the unit tests, on GitHub’s own runners, from the published public plugs and emulator image, whenever the tool or its firmware changes. The faults are not put in their way: they test the tool’s own recovery, which a new final firmware does not change. Where the recovery leans on the firmware’s own behaviour, a major release (16 to 17, say) or release notes touching its Wi-Fi, settings, updates or Berry, run those few scenarios of the fault suite too - F4’s reset, G1, seven-times, and the network gone at L1 and X1 (testing.md).

The S61’s OpenBeken, built anew - above all on a new OpenBeken commit - is run on the S61’s emulator before it is published. This project’s driver and its hook before an OTA’s restart lean on OpenBeken’s own workings: where it keeps its settings and its Wi-Fi’s (0x3e1000, and its flash variables at 0x1e5000), how its HTTP OTA ends in a restart. A commit can move either with the build still passing and the image still holding each command, and the interim firmware would then leave its settings behind for ESPHome or a later OpenBeken to find. The starting plugs are made again with the new image, then six scenarios run, each doing once something the driver or the hook does - the Wi-Fi taken from Sonoff’s firmware, the interim firmware named, fetching ESPHome and erasing itself, kept as OpenBeken, each way back to Sonoff’s firmware - where a pass also checks the names the plug asked DHCP for and, after ESPHome, that the interim’s settings are gone (testing.md):

tools/beken/emulator/fault_suite.py prep
tools/beken/emulator/fault_suite.py list openbeken > openbeken.txt
rm -rf captures/emulation/s61-openbeken   # no pass on the image before counts
AUTOFLASH_S61_SUITE_DIR=captures/emulation/s61-openbeken AUTOFLASH_S61_WORKERS=2 \
    tools/beken/emulator/fault_suite.py batch openbeken.txt

All six must pass.

A real plug, converted from Sonoff to each firmware and between them, before the release: an emulator does not show real flash, radios or timings (testing.md).

6. Release

The update raised the version in tools/autoflash/__init__.py to the changelog’s; merging to main publishes the release (.github/workflows/release.yml), once the tests, lint and leak scan pass on main again, with the firmware files attached. Runs left part way by the release before carry on with the new firmware by themselves: the bundle is built again for the new pins - at A5, or as a run carried on past A5 starts - and a step whose checks were made with the old bundle does them again. A run that had already installed the official app (F2) finishes on the release it began with, using its bundle - or, if this version must build that again (1.2.0’s, from another startup firmware), a bundle of the same release. It reads the app back against that release’s files as usual, and says so: firmware.json keeps each release pinned before (earlier) by its files, and update_firmware.py tasmota adds the one it replaces.