How the tool is tested

Three layers, each catching what the one before cannot:

  1. Unit tests - fast, no plug: the pieces on their own.
  2. The emulator - a virtual S60 running the plug’s real Sonoff firmware (emulator.md): whole conversions, and every fault the suite below puts in their way.
  3. Real plugs - what no emulator shows: real flash, real radios, real timings.

Unit tests

python3 tests/run.py

That runs them side by side, in about 11 seconds on six cores, where python3 -m unittest discover -s tests - the same tests, one after another - takes a minute: most of their time is spent waiting, not computing. It shows only what fails, with what the test printed; python3 tests/run.py test_output runs some, and --shuffle runs them in a random order, to find a test that passes only after another.

They cover the decisions the tool makes - which route to take, what to ask, when to try again, what counts as done - with the plug and the network stood in for; and the parts that have to be exact - the plans the plug carries out, the update protocols, image checks and pins, what is kept and what never is. Every bug found below has a test that fails without its fix. The emulator-check workflow runs them on Python 3.12.10 - the portable Python the start scripts install, which most conversions run on - and, beside it, on 3.10, the oldest the tool runs on: a Mac’s own Python may be older than 3.12, and so may one already installed where the portable one cannot be had.

Side by side, in any order

tests/run.py runs each test in its own turn on one of many processes, in an order unittest never uses, so a test must pass whatever ran before it in its process and whatever runs beside it in the others. A test still running after 60 seconds (--timeout; the longest takes 10) is stopped, reported with where each of its threads was, and the run fails, rather than waiting for ever.

On 2026-10-24 that was tested to exhaustion: 2,000 runs in a row passed, each in a new random order, on Python 3.10, 3.12 and 3.14 and on 2 to 64 processes, after five problems the first hundred runs found had been fixed:

Found What it was Fixed by
A test saw a sleep hold another had just taken the awake tests check the computer’s systemd inhibitors, which one takes while another checks none is left running them as one (TOGETHER in tests/run.py)
“recovery_safeboot size changed” bundle tests left the recovery image in memory, for the next test’s bundle each starting and ending with none
A wait of -1,000,000 s a question left unanswered by one test was taken as the next’s wait output.start() beginning with no question pending
“Address already in use” a port picked free, let go, then taken by another test before the server bound it the server started again on another
A clean-up waiting for ever, once a test server’s shutdown(); never seen again, so not known the time limit, which would now show where it was

Writing a test that keeps it so:

To soak them again, after a change to how tests share anything:

for i in $(seq 200); do python3 tests/run.py --shuffle -j 24 > soak.txt 2>&1 || { grep -m1 seed soak.txt; break; }; done

stops at the first failure and says its seed: --seed N with the same -j runs that order again.

How much they test

Neither measure below is a gate, and no number is a target: a test written to raise one tends to test the stand-ins, not the tool. They say where to look - a decision no test takes both ways, or a check whose reason no test asks - and a person decides what is worth a test there. What only the emulator or a plug can show is tested there.

Coverage says what the tests run. The emulator-check workflow writes it into its summary, by file; to see it here, with what each file misses:

.venv/bin/python -m coverage run --branch --source=tools -m unittest discover -s tests
.venv/bin/python -m coverage report --sort=cover -m

--branch counts each way an if can go, not only each line: a line that ran once counts as covered however its condition went.

Mutation testing says whether the tests would notice a mistake. A tool (mutmut) changes the code one small way at a time - > to >=, or to and, an error’s message to None - and runs the tests on each change. A change some test fails on is caught; one they all still pass is a mistake nobody would see. It takes minutes and asks for judgement, so it is run by hand, on a module that decides something costly, after its tests are written - never in a hook. It runs in a copy of the checkout, from its own virtual environment (mutmut needs pytest, which runs unittest’s tests as they are):

work=$(mktemp -d)
git archive HEAD | tar -x -C "$work"
python3 -m venv "$work/.venv"
"$work/.venv/bin/pip" install mutmut==3.8.0 pytest
cat > "$work/pyproject.toml" <<EOF
[tool.mutmut]
source_paths = ["tools"]
only_mutate = ["tools/autoflash/plans.py"]
pytest_add_cli_args_test_selection = ["tests/test_plans.py", "tests/test_autoflash.py"]
also_copy = [$(git ls-files | cut -d/ -f1 | sort -u | grep -vx -e tools -e tests | sed 's/.*/"&"/' | paste -sd, -)]
EOF
cd "$work" && .venv/bin/mutmut run && .venv/bin/mutmut results

only_mutate is the module; the tests are every file that uses it (grep -l its name in tests/) - one left out leaves the code only it reaches “no tests”. mutmut results lists each change that survived, and mutmut show NAME what it changed. Some can never be caught, because they change nothing: in Plan.check, length <= 0 and length <= 1 never differ, every length being whole sectors. Say so in the tests’ commit, so the next run’s reader need not work it out again.

On 2026-10-27, plans.py, its lines 92% covered, had 144 of 634 changes survive - 31 in Plan.check, whose refusals were tested only as some PlanError, and its limits only past them. Its tests now ask each refusal’s reason and each limit from both sides; 117 survive, most in the plans it builds, which the emulator runs whole.

Linters

tools/setup-dev               # once: creates .venv, installs the linters (and test helpers)
python3 tools/lint.py all

Before the tests, what is likely wrong in the code as written. The pre-commit hook (python3 tools/leak_scan.py install sets it up) runs them on the files a commit changes, and the lint workflow on every file:

Linter On Looks for
ruff Python (ruff.toml) undefined or unused names, likely bugs, a text file opened without encoding=, syntax newer than Python 3.10
shellcheck shell scripts unquoted expansions, mistakes in tests and traps, what sh does not have
actionlint the workflows wrong keys, expressions and types; shellcheck on each run: script
zizmor the workflows, dependabot.yml what an attacker could use: write access a job does not need, a value expanded into a script, a token left in the checkout; with GH_TOKEN set (the workflow sets it), a pinned commit that is not the action’s own, or a version with an advisory
PSScriptAnalyzer PowerShell (PSScriptAnalyzerSettings.psd1) syntax Windows PowerShell 5.1 does not have, which every Windows runs start.ps1 with; likely mistakes
esphome config the ESPHome configs firmware.json builds what ESPHome would refuse to build, checked by the ESPHome image the next build uses (Docker)

The linters come from requirements/lint.txt, pinned with their hashes, but for two: PSScriptAnalyzer needs PowerShell 7.4.6 or newer (pwsh), then pwsh -Command Install-Module PSScriptAnalyzer -Scope CurrentUser; esphome config needs Docker, and pulls the image the first time. A commit on a computer without one is not stopped - the hook says it skipped it, and the workflow runs it - and git commit --no-verify skips them all. archive/ is not linted: it is kept as it was.

The emulator

A conversion with nobody at the keyboard (unattended.md):

tools/emulator/unattended-test.sh --fresh --plug=MAC -- --firmware esphome

Several run side by side with --instance=N (1-30), each its own container, plug and network. Only the eWeLink stages (up to C2) cannot: every emulated plug is a copy of the same real one, and eWeLink keeps one connection per plug - so those run one at a time.

A plug can be kept as a run left it, with what your computer recorded about it (its backup, its runs), and started from again:

tools/emulator/unattended-test.sh --fresh --plug=MAC --save=on-tasmota -- --firmware tasmota
tools/emulator/unattended-test.sh --from=on-tasmota --plug=MAC -- --firmware esphome

That is how every direction is tested: from a plug the tool converted, as a person converting it again would have.

The fault suite

tools/emulator/fault_suite.py puts every fault in the way of every step, in every direction - 1,380 scenarios:

Each scenario converts a saved plug and brings the fault while the step under test is under way - checked from the run’s own log, or marked MISTIMED. Then the tool is run again, as a person would - switching the plug off and on, or joining the plug’s own network, where the run says to - until the plug is converted, or four runs have failed. A scenario passes when the plug ends up converted and checked - Tasmota read back against the official files, ESPHome answering and confirmed, Sonoff’s firmware back on the network - and the run left no firewall rule or helper behind. It is BAD-NAME when the plug went by a name it never should (the names are made from its MAC: device-names.md), on the home network or as a network of its own, or did not end under its converted name; every name it had is kept with the result. It is FAIL-UNEXPECTED when a run stopped with “something unexpected went wrong” - an error the tool did not see coming, a bug whatever the fault - even if the next run converts the plug. It is BAD-TEMPLATE when a plug converted to Tasmota does not answer with the plug’s template - a Tasmota that switches nothing; FAIL-ASKED-AGAIN when a run asked for the go-ahead twice, as a step tried again by itself once did; and FAIL-BACKUP-LINE when a run from a converted plug reported no Sonoff backup though the run that converted it took one.

The emulated computer’s list of networks keeps one the plug put out for a minute after it has gone or been renamed, as a real computer’s does (the emulator’s nmcli; a number of seconds in /run/autoflash-emu/scan-cache in the container changes it, 0 for only what is on air now). A run then sees what a person would: the installer’s network still listed as Tasmota starts, or ESP_ and the access-point MAC for a moment as Tasmota names its own.

To run the suite on another plug - so that names made from the MAC can be seen to follow it - make a test copy of a backed-up one with another MAC, and give that to --plug; its saved starting plugs are its own (the MAC’s last four digits after the name):

python -I tools/emulator/copy_plug.py --from MAC --to 12:34:56:ab:cd:ef
python -I tools/emulator/copy_plug.py --remove 12:34:56:ab:cd:ef
tools/emulator/fault_suite.py --plug=MAC prep           # the starting plugs (eWeLink)
tools/emulator/fault_suite.py list > all.txt            # every scenario, cloud or not
AUTOFLASH_CLOUD_WORKERS=3 AUTOFLASH_ANY_WORKERS=3 \
    tools/emulator/fault_suite.py --plug=MAC batch all.txt   # what has not passed yet
tools/emulator/fault_suite.py report

Add --stub-cloud to prep and batch to give each emulator its own local eWeLink stand-in instead of the shared real cloud: no cloud lock, so every scenario runs in parallel on AUTOFLASH_STUB_WORKERS emulators, and no eWeLink account is needed. Its starting plugs (stub-suite-*) and results (captures/emulation/suite-stub/) are its own: a stub plug’s flash is the other kind, and a stub run’s passes are not the real cloud’s. See cloud-stub.md.

AUTOFLASH_STUB_WORKERS=3 tools/emulator/fault_suite.py --plug=MAC --stub-cloud prep
AUTOFLASH_STUB_WORKERS=3 tools/emulator/fault_suite.py --plug=MAC --stub-cloud batch all.txt

Results, a log per scenario and what each failure leaves (its run records and the plug’s serial output) are kept in captures/emulation/suite/.

Several machines. The scenarios that do not need eWeLink (every step after D1) run on any machine with Docker: copy the repository and the saved plugs (captures/emulation/saved/), give each machine part of the list, and set AUTOFLASH_NO_CLOUD=1 there - only one machine may let its plugs reach eWeLink.

How many at once. Each emulator converting takes about half a CPU core and 120 MB. Past one per core the plugs slow down enough for their timeouts to fail where nothing is wrong, so the load is kept at or under the number of cores. A batch can be scaled while it runs: started with AUTOFLASH_MOST_WORKERS above its workers, only the number written in workers in its results directory take a scenario - raised, more start; lowered, the last ones stop once their scenario ends.

The cloud, one plug at a time. Every emulated plug is a copy of the same real one, and eWeLink keeps one connection per plug, so two online at once knock each other off. A plug’s way to the internet is shut - its connections reset at both ends, so eWeLink lets go of them at once - and opened only for a turn: when its run is about to need the cloud (the eWeLink steps, or the installer sent again), until the plug has downloaded the installer and started it - typically 30-60 s: Sonoff’s firmware that loses eWeLink part way through the download gives it up. Its run ending gives the turn up too; a run after it asks again. Turns are given by how long the run can wait: finding the plug in eWeLink first, then a plug back on Sonoff’s firmware part way, then the rest, and last a C2 scenario’s plug, whose turn starts before its run does. Between two plugs’ turns there is a short pause (5 s) while eWeLink lets go of the last one. The suite can feel out how many at once the cloud allows - one more at a time while the cloud scenarios pass, or one fewer at each trouble - and lengthen the pause when eWeLink is the trouble (AUTOFLASH_CLOUD_MOST, AUTOFLASH_CLOUD_BACK_OFF, AUTOFLASH_CLOUD_PAUSE_UP).

What it cannot show

Every network a plug raises of its own - Tasmota’s, ESPHome’s, the mover’s and the installer’s - can be joined from the emulated computer, as a person’s laptop or phone would join it (emulator.md).

Results

On 22 October 2026 the suite ran every scenario on 2.0.1, against the cloud stub, on four machines at once - 22 emulators, about five hours. Every failure was looked into, the cause fixed, and the scenario run again on the fixed code. The latest result of each:

Direction Scenarios Passed A phase’s retries spent Nothing to corrupt
Sonoff to Tasmota 369 368 1 -
Sonoff to ESPHome 206 204 2 -
Tasmota to ESPHome 83 82 1 -
Tasmota to Sonoff 199 198 - 1
ESPHome to Tasmota 318 317 1 -
ESPHome to Sonoff 168 167 - 1
Tasmota to Tasmota 2 2 - -
All 1,345 1,338 5 2

Five had the Wi-Fi drop more than once in one Berry phase: the phase’s 20 seconds of retries are counted from its first failure and never begun again, so a later drop found them spent and the phase stopped. The run tried the step again, and each plug converted; but a phase should ride out short drops by itself. #5 fixed it: the retries now count only time spent failing. With it, 38 scenarios of drops, packet loss and a weak signal at every phase that moves data, run three times over, all passed but one, whose drops came just before its phase began - as G1 checked the plug, then as it was started. Those are fixed too (below). The two with nothing to corrupt are a packet changed during R2, the check that Sonoff’s firmware is back: it carries no data to change.

The longest one phase kept the plug busy was 86 seconds (L1, on a weak signal). A real S60TPG once stopped answering after 70-90 seconds busy, while an S60TPF converted after two minutes: so how long is recorded and reported, not judged.

Then a few against the real eWeLink, on a day when some of its EU servers for plugs turned connections away - four of six in the morning, one by midday: fresh conversions to each firmware, and two faults at C2. Sonoff to ESPHome converted (2 min 45 s), and Sonoff to Tasmota on its third run after an outage at C2; a fresh Sonoff to Tasmota never got the installer to the plug, in four tries, as the plug’s connection to eWeLink, or the test computer’s, kept failing. The run put some of that down to the test computer’s firewall (fixed, below).

2.0.2. Its steps that write firmware check each block on the plug rather than sending it back, so they are shorter (below), and every fault the suite times by them was retimed. Before release, 301 scenarios were run on it, on the four machines in about an hour: every one at E2, G1, I1, L1 and R1, where the change is, and samples at D5, H1 and C3 for the other fixes. All passed, the retimed ones once their faults were placed by the new lengths, and the one whose 1.2.0 run - which the scenario stops at G1 - first stopped by itself before it got there, at its F4, when run again.

Between them, those steps kept the plug busy 18 seconds where they took 31, over two runs of 52 scenarios of drops, loss, a weak signal and corrupted packets at each. The longest any phase kept it busy was 78 seconds, on a weak signal for a minute of it.

Before the stub, on 18-19 October, the suite ran 1,453 scenarios on the code as of 19 October, the cloud ones against the real eWeLink: all passed but the same two with nothing to corrupt.

Still open

What it has found

Each fixed, with a test, and the scenario run again. On 22 October:

In the October 18-19 regression:

In the rounds before it, from 16 October:

Before that:

Real plugs

Real plugs are converted with each one powered through a second smart plug, so its power can be cut on purpose. So far, Tasmota to ESPHome and back has been run on a real S60TPG: every write read back, the final check of the Tasmota plug matching all four regions, and the relay and power meter checked through each firmware. Going back to Sonoff’s firmware has been tested on the emulator only: neither test plug has its backup on the computer used.

Fixes sent upstream: the bugs this testing found in other projects - Tasmota, arduino-esp32, ESP-IDF and others - and the fixes sent to them.