Files
thermostat-hw/sim

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 through lv_fs, which needs a registered driver and drive-letter paths. sim_main.c encodes to memory and uses fwrite.