Files
mattandClaude ea1b8b8d15 Two indoor sensors, wind compass, and stop framebuffer cache thrashing
Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.

The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.

  - The needle does not animate. A spin would look better, but every animated
    frame is a redraw, and redrawing is what starves the bounce refill ISR.
  - Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
    being pushed reports whatever it last settled on, which on this station
    is a steady 0 degrees -- a confident needle pointing due north at zero
    wind. The live snapshot caught exactly that.
  - The trend is local, not HA's 10-minute average, so the window is ours to
    pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.

That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.

Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.

On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.

Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:58:41 -07:00
..
2026-08-04 11:55:02 -07:00
2026-08-04 11:55:02 -07:00
2026-08-04 11:55:02 -07:00

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.