|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
Status: Accepted · Recorded: 2026-08
The component's logic — frame parsing, cryptography, exchange and pairing state machines, poll scheduling, queue coalescing — is ordinary C++ with no real dependency on ESP32 hardware. But it is written as an ESPHome component, so it includes ESPHome headers for Component, logging, GPIO, preferences, and helpers.
Testing by building ESPHome would pull a Python toolchain, a cross-compiler, and a firmware build into every test run: slow, needs Docker, and couples the suite to whichever framework version happens to be installed.
Option 3. A small set of stub headers under the test tree reimplements the narrow slice of ESPHome the component uses. Tests build and link with plain g++ — no ESPHome, no ESP-IDF, no Docker, no hardware.
The stubs are not merely empty shims: where a test needs to observe an interaction, the stub records it. The Component stub's set_timeout/ set_interval/cancel_* (and the two entry points production code reaches directly through App.scheduler) are all backed by one pending-timer registry (test_scheduler::Registry), so a test can have several devices' polls pending at once — not just the single most recent call — and either assert on them directly (test_scheduler::pending()) or drive them by calling run_due() (fire whatever is due right now) or run_until(target_ms) (walk the clock forward stop by stop, firing each deadline it crosses, the way a real device's loop() would). The compat fields a test could already read or write by hand (last_timeout_name_, last_timeout_callback_, …) still exist and are still updated on every call, for tests that fire a captured callback directly instead.
Time itself is a separate stub, test_clock (tests/include/esphome/core/ hal.h): a default legacy mode, bit-for-bit the original per-call counters, and an opt-in manual mode (test_clock::ManualClock, RAII-scoped to one test) where millis()/micros() read off a single clock a test moves explicitly. Manual mode is what makes run_until() meaningful; it is opt-in because the radio chip drivers busy-poll the clock in a loop with no other exit condition, which would spin forever under a clock that never advances on its own — a built-in guard aborts the test if that happens, rather than hanging CI.
Protected internals are reached through small Testable* subclasses that promote the members a test needs. The production build keeps normal C++ access control — there is no test-only weakening of the real classes.
flowchart TB
SRC[["components/home_io_control/*.cpp<br/><b>one set of sources</b>"]]
HOST["Host build — plain g++<br/><i>seconds, any Linux, no hardware</i>"]
FW["Firmware build — ESPHome + ESP-IDF<br/><i>Docker, cross-compiler</i>"]
STUB["stub headers<br/><i>Component · log · GPIO ·<br/>preferences · helpers</i>"]
REAL["real ESPHome headers"]
T["unit tests + ASan/UBSan"]
C["compiles for the target"]
GAP["fidelity gap:<br/>a drifted stub or a missing<br/>feature macro passes here,<br/>fails there"]
SRC --> HOST --> STUB --> T
SRC --> FW --> REAL --> C
HOST -.-> GAP
FW -.-> GAP
classDef gap stroke:#c0392b,stroke-width:3px
class GAP gap
Both builds compile the same component sources; only the headers underneath differ. That is the whole idea, and also the whole risk.