Thermostat HMI

ESP-IDF firmware for the Elecrow CrowPanel 7" Advance (ESP32-S3-WROOM-1-N16R8, 16MB flash + 8MB octal PSRAM, 800×480 IPS, GT911 capacitive touch).

Board variant matters. Elecrow sells a Basic CrowPanel 7" and an Advance CrowPanel 7". They share a form factor and almost nothing else: every RGB data pin, every sync pin, the pixel clock, the porches, the sync polarities and the touch I2C pins are different, and the Advance drives its backlight over I2C instead of from a GPIO. This firmware targets the Advance. Building it against Basic documentation yields a black screen and a silent I2C bus.

A wall dashboard driven by Home Assistant over the LAN: outdoor and indoor conditions from an Ambient Weather station, sunrise/sunset, rain, a five-day forecast, and on/off plus brightness control for six lights. Thermostat control (setpoint, HVAC relays, scheduling) is not implemented yet.

Components

Path Purpose
main/drivers/display RGB parallel panel via esp_lcd, double-buffered in PSRAM
main/drivers/touch GT911 capacitive touch over I2C, registered as an LVGL input device
main/ui/lvgl_port.c LVGL tick source, service task, and locking
main/ui/ui_manager.c Screen layout and update entry points
main/ui/icon_font.c Generated ~6KB MDI icon subset — see tools/make_icon_font.py
main/wifi WiFi station bring-up
main/api/ha_client.c Home Assistant REST transport (device only)
main/api/ha_data.c Response parsing and presentation helpers (shared with the simulator)
main/api/ha_config.h Entity IDs for this installation
main/api/ambient_weather.c Ambient Weather cloud client — fallback only
sim/ Desktop simulator for the UI — see sim/README.md
tools/ ha_discover.py, ha_snapshot.py

Setup

cp main/app_config.h.example main/app_config.h
# edit main/app_config.h: WiFi credentials + HA_TOKEN

main/app_config.h is gitignored — it holds your WiFi password and the Home Assistant token. Entity IDs are not secrets and live in main/api/ha_config.h, which is tracked.

Confirm the entity IDs still resolve after any change in HA:

export HA_TOKEN='...'          # HA: your user -> Security -> Long-lived tokens
python3 tools/ha_discover.py

A wrong entity ID fails silently as "unavailable" rather than as an error.

Build & Flash

idf.py set-target esp32s3
idf.py build flash monitor

Requires ESP-IDF v5.3 or newer (the code uses the i2c_master driver API and dma_burst_size).

Home Assistant

Everything on screen except the forecast comes from one POST to /api/template: about 430 bytes, measured at ~85 ms on the LAN. The Jinja template lives in main/api/ha_template.h and is built by the preprocessor from the same HA_LIGHT_LIST X-macro as the C table, so the request and the code that reads the reply cannot drift apart.

Three things fall out of doing it this way, and all three are load-bearing:

  • No SNTP, and no timezone database. HA renders sunrise, sunset, clock and date as local-time strings. The board's own clock still starts at the epoch and it no longer matters — but note that means nothing on this device knows the time, so anything needing a real clock (scheduling, say) has to solve that separately. There is an unused RTC at I2C 0x51.
  • No TLS. Plain HTTP to a box on the same switch. The cloud client pays a multi-second handshake; this does not, which matters because the caller pauses rendering for the duration. Pointing HA_HOST at an https:// instance throws that away.
  • Unavailable sensors arrive as JSON null, never a sentinel number. -1 mph of wind is impossible, but -1 °F of dew point is not.

The forecast is a second, slower request (every 30th poll). It cannot be part of the template: HA removed the forecast attribute from weather entities in 2024.4, and templates cannot call services.

The wind sensors are dead

The station's anemometer has been offline since 2026-07-27: wind_speed, wind_gust, wind_direction and feels_like all read unavailable, and the WIND tile shows -- by design. It is deliberately not backfilled from the met.no forecast — regional modelled wind under a heading that means "my yard" is worse than showing nothing.

sensor.moonlight_beach_max_gust is a trap: it is not unavailable. It still reports its last value from 2026-07-28 and will render as a plausible live number. Anything that uses it needs a staleness check first.

Fallback

When HA cannot be reached, ambient_weather.c fills in outdoor temperature, humidity and wind from the cloud API and the status line says so. Indoor, sun, forecast and lights have no cloud equivalent and hold their last values.

Hardware (Advance)

RGB panel (16-bit RGB565)

Signal GPIO
DE 42
VSYNC 41
HSYNC 40
PCLK 39
R0–R4 7, 17, 18, 3, 46
G0–G5 9, 10, 11, 12, 13, 14
B0–B4 21, 47, 48, 45, 38

Timings: 18 MHz pixel clock, HFP 8 / HPW 4 / HBP 8, VFP 8 / VPW 4 / VBP 8. Both syncs idle high; PCLK is pclk_active_neg.

I2C bus — GPIO 15 (SDA) / 16 (SCL)

Address Device
0x30 STC8H1K28 board controller — backlight and GT911 reset
0x51 RTC
0x5D GT911 touch

The GT911 has no ESP32 reset or interrupt GPIO on this board. The STC8H1K28 holds it in reset, so backlight_init() must run before touch_init() or the touch controller NACKs every address.

STC8H1K28 protocol is single raw bytes, no register addressing: 0x19 wake, then 0x10 and 0x18 to configure and release the GT911. Brightness is 0x00–0xF5 inverted (0x00 = full) on v1.3; v1.2 uses a narrower 0x05–0x10 range instead.

Display architecture

Two 800×480×2 framebuffers in PSRAM, LVGL in RENDER_MODE_DIRECT. LVGL draws into whichever buffer the LCD is not scanning; the last flush of a refresh hands that buffer to the panel, which switches to it. Nothing ever writes into the buffer being displayed — copying into the live framebuffer races the LCD's own reads, and the resulting corrupt pixels persist indefinitely because LVGL does not know they are wrong and never reinvalidates them.

A 16-scanline bounce buffer stages each chunk in internal RAM. 20 scanlines would be better but needs 64KB of internal RAM and fails to allocate once WiFi is up.

Known limitation: occasional left-edge artefacts

Short stale lines sometimes appear at the left edge, most often right after a weather fetch. The panel needs ~34 MB/s sustained from PSRAM at the 18MHz pixel clock, and 80MHz octal PSRAM is marginal for that. When the bounce refill ISR runs late — WiFi interrupt activity is enough — the panel scans out whatever was left in the buffer.

The pixel clock is not a usable lever: below roughly 40Hz refresh (about 15MHz) the panel stops locking and the image rolls vertically instead.

The real fix is 120MHz PSRAM, which is what the vendor's Arduino build uses (-DCONFIG_SPIRAM_SPEED_120M=1). On ESP-IDF 5.3 that is not safely reachable: selecting it raises the shared MSPI core clock, and IDF only ships flash timing tables pairing 120MHz PSRAM with 120MHz flash. Flash above 80MHz requires High Performance Mode and a DC-aware bootloader.

UI development

main/ui/ui_manager.c and main/api/ha_data.c are compiled unmodified into a desktop simulator, so layout work does not need a flash cycle:

cmake -S sim -B sim/build -G Ninja && cmake --build sim/build
./sim/build/thermostat_sim                          # SDL window, mouse = touch
./sim/build/thermostat_sim --png /tmp/shot.png      # headless capture

Capture real states to iterate against, instead of waiting for reality:

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

See sim/README.md for what it does and does not reproduce. It says nothing about PSRAM bandwidth or panel timing.

The font lists in sim/lv_conf.h and sdkconfig.defaults must match. A size enabled in one build and not the other makes the simulator lie about layout. Worse, sdkconfig.defaults only seeds a new sdkconfig — editing it does nothing to an existing one, so change sdkconfig too (or delete it and let it regenerate).

Gotchas

  • LVGL does not bubble events to parents. An event handler on the screen object never sees taps that land on a child widget, which looks exactly like dead touch zones. Light tiles therefore carry their own handlers, and the labels inside them are left non-clickable so taps reach the tile.
  • Montserrat has no · (U+00B7) or — (U+2014). Both render as tofu boxes. weather_get_string() emits a middot, but only into a log line. Anything reaching a label must stay in Latin-1; the UI uses --.
  • LV_LABEL_LONG_DOT needs an explicit height to truncate. With a content-sized height the label wraps to a second line first and only then adds dots — landing on top of whatever is below it.
  • Icons are label text, not images: single glyphs from a subset MDI TTF rasterised by tiny_ttf (tools/make_icon_font.py regenerates main/ui/icon_font.c / ui_icons.h; needs network + fonttools, so the generated files are committed). Codepoints are resolved from the MDI CSS at generation time — never hardcode one, MDI reassigns them between majors.
  • Light tiles are optimistic. A tap flips the tile immediately and holds against poll data for up to 5s until HA confirms (light_hold_t); commands are sent on finger-lift only, and poll updates never touch a pressed slider. Breaking any of those three rules brings back the "button waits a second, then the knob jumps out from under your finger" feel.
  • lv_label_set_text() invalidates unconditionally, even when the text is identical — so setting an unchanged value still forces a repaint. All label updates go through label_set_if_changed().
  • WiFi must be brought up before display_init(). The bounce refill ISR reads PSRAM, which shares the cache with flash; an NVS write while the panel is running faults it with "Cache disabled but cached memory region accessed".

Partitions

Two 4 MB OTA slots (ota_0 / ota_1) with otadata, laid out in partitions.csv. The app is ~1.4 MB, so the slots are generous — but the layout is fixed once devices are fielded, and re-partitioning later means reflashing each one over USB. ~7.9 MB of the 16 MB is left unallocated; adding a partition to free space is safe, moving an existing one is not.

OTA is not yet implemented, and it is not a drop-in. Writing an OTA image is a flash write, and the bounce-buffer refill ISR reads PSRAM, which shares the cache with flash on the S3 (see Memory notes). Downloading an update while the panel is scanning will fault that ISR. Any implementation must stop the panel for the download and write, or reboot into a dedicated updater mode first.

Memory notes

Two 800×480×2 framebuffers (750 KB each) live in PSRAM. The 8 MB octal PSRAM at 80 MHz is required — QSPI cannot sustain the RGB refresh.

Do not enable CONFIG_SPIRAM_FETCH_INSTRUCTIONS / CONFIG_SPIRAM_RODATA: they contend with the panel for PSRAM bandwidth. The bounce buffer is enabled (the panel cannot stream straight from PSRAM), which is exactly why flash writes are dangerous while the panel runs: the refill ISR reads PSRAM, which shares the cache with flash on the S3, so an NVS write faults it with "Cache disabled but cached memory region accessed". All flash writes happen before display_init() and CONFIG_ESP_WIFI_NVS_ENABLED is off to keep the WiFi stack from writing later.

S
Description
No description provided
Readme
476 KiB
0 Stars 1 Watchers 0 Forks
Languages
C 93.9%
Python 4.7%
CMake 1.4%