Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).
Control loop (main/control/thermostat.c) is pure logic: no ESP-IDF, no
network, no clock of its own. The caller passes a temperature and a
millisecond timestamp, which is what lets the simulator and the test
harness run the identical decision code.
Equipment protections, all verified by tools/thermostat_test.c:
deadband 1.0F stops the burner chattering at the setpoint
minimum on 3 min a shorter call is all ignition wear, no heat
minimum off 5 min back-to-back ignitions are what kill igniters
cycles/hour 5 standard gas default; catches a flapping sensor
boot lockout 60 s a boot loop must not machine-gun the gas valve
That test drives the state machine with synthetic time, so two hours of
furnace behaviour runs in milliseconds. It exits non-zero on failure and
is worth re-running after any edit to thermostat.c -- this switches a gas
valve.
Two deliberate decisions:
- G is never energized during a heat call. The furnace runs its own
blower with a fan-on delay that waits for the heat exchanger and a
fan-off delay that scavenges residual heat; driving G ourselves would
blow cold air at people and throw that heat away. G is manual
circulation only.
- The control loop starts before every early return in app_main. Those
returns are all "no WiFi" or "no HA token", and none of them is a
reason to leave a house unheated.
Relay pins are IO20 heat / IO19 fan on the UART1-OUT plug, measured on
this board since Elecrow documents the GPIO numbers but not the connector
pin order. Heat is on IO20 because it is USB D+, the line carrying the
hardware pull-up -- with an active-low module it fails toward "no heat"
rather than "heat stuck on" if the USB PHY is ever re-enabled. Both pins
were confirmed free of the onboard mic before use.
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG and CONFIG_ESP_PHY_ENABLE_USB are now
off to release them; the console is on UART0 via the CH340 regardless.
UI: the indoor card gains a status dot, mode and fan buttons, and a
setpoint column with finger-sized up/down targets. The reading grid drops
from six cells to four to make room -- wind was the one to cut, since the
station anemometer has been dead since 2026-07-27 and that cell has never
shown a number. Wind now rides on the outdoor cell's sub-line and will
reappear on its own if the sensor is fixed. Sunrise and sunset share a
cell, showing whichever comes next.
Known weakness, documented in README and top of TODO: indoor temperature
comes from Home Assistant over the network, so an HA outage ages the
reading until the 15-minute failsafe forces heat off. A local I2C sensor
removes the failure mode entirely and is the next thing to add. The
setpoint also resets to 68F on reboot, because persisting it means an NVS
write and a flash write while the panel scans faults the bounce-buffer
ISR.
Co-Authored-By: Claude <noreply@anthropic.com>
Desktop simulator
Runs the firmware's UI on a Linux desktop so layout work does not need a
flash-and-look cycle. main/ui/ui_manager.c is compiled unmodified — the
same source the device runs.
Build
cmake -S sim -B sim/build -G Ninja
cmake --build sim/build
LVGL comes from managed_components/lvgl__lvgl, which the ESP-IDF component
manager populates. If that directory is missing, CMake says so; run
. ~/esp/esp-idf/export.sh && idf.py reconfigure once to fetch it.
No ESP-IDF environment is needed to build the simulator — only a host
compiler, CMake, Ninja and libsdl2-dev.
Run
./sim/build/thermostat_sim # SDL window, mouse acts as touch
./sim/build/thermostat_sim --png shot.png # render one frame, write a PNG, exit
./sim/build/thermostat_sim --png shot.png --settle-ms 1500
The --png mode is what makes UI review possible without a human at a screen:
it renders headless, so it works over SSH and in CI, and the resulting image
can be inspected directly.
Clicking a light tile in the window calls the same ui_set_light_cb path the
firmware uses; the simulator applies the change locally and logs the command,
so tiles respond with no Home Assistant present.
Snapshots
The interesting screens — rain falling, a light dimmed, a sensor dropping out — do not happen on demand, and live data changes underneath a before/after comparison. So capture real responses and render those:
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/rainy.json \
--forecast sim/snapshots/rainy.forecast.json
Snapshots are plain JSON and are meant to be hand-edited into states that have
not occurred yet — sim/snapshots/evening.json was made that way, to check
the on/dimmed/unavailable tile styling while every real light was off.
ha_snapshot.py does not carry its own copy of the Jinja template. It
reads it from thermostat_sim --print-template, which the preprocessor builds
from main/api/ha_template.h, so a snapshot cannot be captured with a
template the firmware does not use.
How it stays honest
A simulator that renders differently from the panel is worse than none, so these are deliberately pinned to the device:
| Property | Value | Enforced by |
|---|---|---|
| Resolution | 800×480 | PANEL_H_RES/V_RES in sim_main.c |
| Colour depth | RGB565 | LV_COLOR_DEPTH 16 in lv_conf.h |
| Render mode | DIRECT |
lv_conf.h / headless_display_create() |
| LVGL version | whatever IDF resolved | builds the same managed_components tree |
| Fonts | Montserrat 12–48 | lv_conf.h and sdkconfig.defaults |
The font lists must be edited together. Enabling a size in one build and not the other makes the simulator lie: text silently falls back to a different size and every metric shifts.
What it does not simulate
- PSRAM bandwidth, the bounce-buffer refill ISR, and RGB scan timing. The left-edge artefacts in the root README are invisible here by construction. This tool answers "does the layout look right", never "does the panel keep up". Timing-sensitive changes still need hardware.
- Touch hardware. The mouse is a stand-in for the GT911: one contact point, pixel-accurate, no palm rejection, and none of the I2C contention the real panel has with the backlight controller.
- Anything in
main/drivers/. Those touch real peripherals and are never compiled here.
Layout
| Path | Purpose |
|---|---|
sim_main.c |
Entry point: display setup, PNG capture, mock data |
lvgl_port_sim.c |
Host implementation of the firmware's lvgl_port.h |
compat/ |
Stand-ins for the ESP-IDF headers shared sources include |
lv_conf.h |
Host LVGL config, mirroring the device's Kconfig settings |
compat/ exists so firmware sources compile as-is instead of growing
#ifdefs. If a newly shared source fails to compile, extend compat/ — do
not fork the firmware source.
Gotchas
- Montserrat has no
·(U+00B7). It renders as a tofu box.weather_get_string()uses one, but its output only ever reaches a log line. Anything routed to an on-screen label must avoid it. lodepng_encode32_file()does not work here. LVGL patches its bundled lodepng to do file I/O throughlv_fs, which needs a registered driver and drive-letter paths.sim_main.cencodes to memory and usesfwrite.