Testing and QA
What tests exist, what each one holds down, and the command that runs it. Nearly all of it runs
without an A/C or a board. Why the suite is shaped this way is in
firmware/docs/04-qa-strategy.md.
The layers
| # | Layer | Proves | Hardware | Where |
|---|---|---|---|---|
| 1 | Host unit tests | the codec, the mapping, the bus scheduler, the retry rules and setup mode produce and accept the right bytes and make the right decisions | none | firmware/test/test_*.cpp, test_*.py |
| 2 | Device simulation | the command and status loop against a modelled A/C | none | firmware/test/virtual_ac.py |
| 2b | Host end-to-end | the hub and the entities as they ship: what Home Assistant sees and which bytes reach the A/C | none | firmware/test/host_e2e/ |
| 3 | Build checks | the YAML, every platform schema, and a real compile of the board files | none | esphome config, esphome compile, in CI |
| 4 | Bench | a flashed board against the simulator on a USB adapter | a board | dev.py bench esphome |
| 5 | Hardware in the loop | timing, radio, the real A/C | a unit | firmware/test/hil_esphome_actuation.py |
The commands
| Command | What it runs | Time | Needs |
|---|---|---|---|
firmware/test/run_tests.sh |
every host layer below | about a minute | g++, python3. With esphome installed it also runs the configuration matrix |
firmware/test/sanitize.sh |
every C++ host test again under AddressSanitizer and UBSan, with g++ and clang++ |
about 25 s | a missing compiler is skipped |
firmware/test/fuzz/run.sh --smoke |
replays the fuzz seeds, corpus and regressions through every harness. Part of run_tests.sh |
10 to 20 s | g++ or clang++ |
firmware/test/fuzz/run.sh <seconds> [harness...] |
fuzzes each harness for that long | as given | clang++ with libFuzzer |
firmware/test/coverage/run.sh |
line and branch coverage of the pure component code under the host tests | about 15 s | gcovr (fetched with uvx) |
firmware/test/coverage/mutate.py |
mutation testing of the same code: which changes to the source no test notices | minutes | uvx |
python3 firmware/test/test_esphome_config_matrix.py |
the configuration schema through esphome config. Part of run_tests.sh |
seconds | esphome |
python3 firmware/test/host_e2e/run.py |
the host end-to-end scenarios | about two minutes | esphome, g++ |
python3 firmware/scripts/dev.py test esphome |
run_tests.sh, then esphome config on one board file |
esphome |
|
firmware/scripts/cpp-lint.sh check |
clang-format, the custom rules and clang-tidy | installs its pinned tools on first use |
The coverage and mutation scripts write to a temporary directory, never into the tree. Neither is
part of the default suite. mutate.py runs at most four mutants at once, each with a memory
limit and a timeout of its own, and prints those limits when it starts
(firmware/docs/04-qa-strategy.md).
What run_tests.sh holds down
It prints a heading per layer, in the order of this table, and ends with ALL QA LAYERS PASSED.
| Layer | Test | What it holds down |
|---|---|---|
| 1a | test_codec.cpp |
the reference driver’s builders and parsers against golden frames |
| 1b | test_reference_map.cpp |
the reference fan ladder, setpoint clamp, running state and power estimate |
| 1c | test_esphome_map.cpp |
the reference driver’s first ESPHome mapping of modes, fan names and presets (reference-driver/esphome_aircon_map.h) |
| 1d | test_esphome_codec_parity.cpp |
the component’s codec equals the reference driver, byte for byte, over exhaustive and seeded random inputs |
| 1e | test_esphome_bus.cpp |
the bus scheduler against a simulated A/C: timing, link loss, retries |
| 1f | test_esphome_edge_cases.cpp |
shadow sync rules, boundaries, damaged byte streams |
| 1g | test_esphome_confirm.cpp |
each confirm and retry decision, then the real CommandTracker (the class the hub uses) against a model of a unit that loses frames and overrides values |
| 1h | test_esphome_entities.cpp, test_esphome_tables.py |
the climate request as a plan, what a status frame shows, the switches and the sleep select. The Python schema tables against the C++ constants they name |
| 1i | test_esphome_setup_request.cpp |
the remote’s setup request: debounce, lockout, the heartbeat flag |
| 1j | test_esphome_uart_policy.cpp |
when the stock module’s bus port is restarted |
| 1k | test_esphome_setup_edges.cpp |
every ordering of the two request bits, the lockout, link loss |
| 1l | test_esphome_bus_stress.cpp |
scheduler edge cases and stress: the clock wrap, damaged replies, seeded random runs |
| 1m | test_esphome_codec_gaps.cpp, test_esphome_map_gaps.cpp |
the gaps that coverage and mutation testing found |
| 1n | test_esphome_setup_mode.cpp |
the decisions of Wi-Fi setup mode |
| 1o | fuzz/run.sh --smoke |
the fuzz corpus, the known failures and framing edge cases, under ASan and UBSan |
| 1p | test_esphome_config_matrix.py |
valid configurations validate with no warning, invalid ones (firmware/esphome/tests/invalid/) are rejected with the expected message, and every board file gives the same entities. Skipped without esphome |
| 2 | inline | virtual_ac.py and the frame decoder in reverse-engineering/tools/ agree in both directions |
| 3 | test_image_chain.py |
the stock module’s image signing chain (amebaz2_image.py): walk, verify, re-sign |
| 4 | test_dev_release.py, test_matter_convert.py |
dev.py (build cache, board files, command line) and matter-convert.py |
| 5 | version-lint.py, test_version_lint.py |
the firmware version, the changelog and the ESPHome pin agree, and the lint fails on trees that have to fail |
| 6 | test_dev_edges.py, test_scripts_edges.py, test_flasher.py, test_hil_restore.py, test_mutate_limits.py |
edge cases of the tooling: dev.py, the scripts, the commit hook, the lint lists, the clip flasher’s checks, the limits mutate.py puts on a mutant |
To run one C++ test alone, compile it from firmware/test/ as run_tests.sh does:
g++ -std=c++11 -Wall -Istubinc -I. -Ireference-driver \
test_codec.cpp reference-driver/hisense_rs485.cpp -o test_codec && ./test_codec
The reference driver
firmware/test/reference-driver/ is the project’s first bus driver, which ran on real units for
months. It is frozen. The component carries its own port of the codec, and layer 1d compares the
port with that hardware-validated original on every push. Change the reference only to add a
protocol fact both sides must agree on, and say so in the commit.
The virtual A/C
firmware/test/virtual_ac.py speaks the validated bus: it answers status polls with a 160-byte
status frame and applies command frames to its state. It attaches to a pseudo-terminal (--pty),
a serial port (--port, which needs pyserial) or a TCP socket (--connect). It prints
# virtual A/C up. initial: {...}, one line per handshake frame it answers, and a [state] {...}
line whenever a command changes its state.
A flashed board against it is the bench stage: Build, Flash and Test.
The host end-to-end test
firmware/test/host_e2e/ runs the component as it ships, hub and entities included, with no
board. host.yaml includes packages/hisense-ac.yaml unchanged and builds it for ESPHome’s
host platform: a Linux program whose UART is a pseudo-terminal. run.py puts the virtual A/C
on the other end, connects over the native API as Home Assistant does, and checks two things for
every action: what the entities show, and which bytes reached the A/C.
python3 firmware/test/host_e2e/run.py # build, then the default scenarios
python3 firmware/test/host_e2e/run.py --full # the long ones too
python3 firmware/test/host_e2e/run.py --list # every scenario and how long it takes
python3 firmware/test/host_e2e/run.py holdoff # one scenario
python3 firmware/test/host_e2e/run.py --coverage # which lines of the hub and entities the scenarios reach
python3 firmware/test/host_e2e/run.py --tidy # clang-tidy over the hub and the entities
firmware/test/host_e2e/soak.sh 10 # the default set ten times, to find a flaky check
Without esphome it prints that it was skipped. The first build downloads PlatformIO’s native
platform, which is why it is not part of run_tests.sh.
- The timers are real. The hub runs its 1 s bus cycle, 4 s hold-off and 10 s special-mode
pacing on the wall clock. Scenarios run four at a time (
--jobs). The default set takes about two minutes,--fullabout three. - The entity list is a golden file. A changed name, object id, unit, device class, category,
icon or climate trait fails the
bootscenario. After a change that is meant, runrun.py --update-golden bootand commitentities.golden.json. Entities and Diagnostics is written from that file. - Known failures. A defect the test reproduces is a
known_failure(...)inscenarios.py: green while the defect is there, red on the day it is fixed.
Hardware in the loop
firmware/test/hil_esphome_actuation.py <host> drives every control of a real node over the
native API, checks that nothing else moved, and ends with the bus counter deltas: retries are
listed, and a rise in Failed commands fails the run. It stays out of run_tests.sh on purpose.
It restores the state it found, also when a step fails, on Ctrl-C and at its hard timeout
(--timeout). It reads the unit back and fails if anything is not as found. A unit that is
running when it connects is taken to be in use and is not touched without --allow-running. The
API key comes from --key or from secrets.yaml.
For byte-level questions on a live bus, reverse-engineering/tools/decode_ac_frames.py --port <tap>
decodes what is on the wire, and firmware/esp32-recon/ is a console on the bus.
C/C++ lint
Every C/C++ tree here is held to ESPHome’s style and checks: .clang-format and .clang-tidy
at the repository root, narrowed per directory where a tree has a real constraint.
firmware/scripts/cpp-lint.sh check [PATH...] # what CI runs
firmware/scripts/cpp-lint.sh fix [PATH...] # applies the fixable part, then reports the rest
- clang-format 13.0.1 and clang-tidy 22.1.8 are ESPHome’s pins. The script refuses other
versions and installs the pinned ones into
~/.cache/hisense-w41h1/cpp-linton first use (CLANG_FORMATandCLANG_TIDYoverride). - The custom rules (
cpp-lint.py) are the portable part of ESPHome’sci-custom.py. A line withNOLINTis exempt. Say why next to it. - clang-tidy runs on what compiles on a host: the reference driver, the host tests, the codec
port and the bus scheduler. The hub and the entities need ESPHome’s headers, so they get
clang-tidy through
host_e2e/run.py --tidy. firmware/scripts/esphome-upstream-check.shcopies thehisense_accomponent and its tests into a checkout ofesphome/esphomeand runs the scripts their CI runs. By default it checks against the ESPHome release this repository builds with.ESPHOME_UPSTREAM_REF=devchecks against their development branch.
What CI runs
Every push and pull request gets the lints, run_tests.sh, the sanitizer run and
esphome config on every board file. A change to the firmware adds real compiles, the host
end-to-end test and ESPHome’s own gates. A nightly run fuzzes every harness for ten minutes,
compiles the example board files and runs the long end-to-end scenarios. The jobs, one by one:
Repo Map and Build Pipeline.
The rule for glue code
The hub only runs in the host end-to-end test, on real timers, where one case costs seconds. So a decision (is this write my own echo, is this command confirmed, which special-mode frame goes next) is written as a pure function in one of the component’s pure headers, with a host test. A feedback-loop bug once escaped review because it lived in a layer no test could reach.