Files
thermostat-hw/README.md
T
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

401 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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:
```bash
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
```bash
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`).
## Thermostat control
Single-stage gas furnace, no A/C. The control loop is in
`main/control/thermostat.c` and is **pure logic** — no ESP-IDF, no network, no
clock of its own. The caller supplies a temperature and a millisecond
timestamp; that is what lets the desktop simulator and the test harness run
the identical decision code.
| Protection | Value | Why |
|---|---|---|
| Deadband | 1.0 °F | stops the burner chattering at the setpoint |
| Minimum on | 3 min | a shorter call delivers almost no heat for a full ignition's wear |
| 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 |
| Sensor staleness | 15 min | see below |
Verify after any change to that file — it switches a gas valve:
```bash
gcc -Wall -Wextra -I main/control -o /tmp/therm_test \
tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test
```
Two decisions worth knowing:
- **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 any of app_main's early returns.** Every
one of those is about WiFi or HA being unusable, and none of them is a
reason to leave a house unheated.
### The sensor is the weak link
Indoor temperature arrives over the network from Home Assistant. If HA stops
answering, the reading ages; after 15 minutes the state becomes `NO_SENSOR`,
heat is forced off and the card turns red. Running blind on an old number
could overheat the house or let it freeze, and neither is acceptable.
This is a deliberate compromise, not a design. **The real fix is a local
sensor on the I2C bus**, which removes the failure mode entirely — an SHT30
or BME280 at a free address. Note the panel dissipates ~5 W, so it must be
remote from the enclosure or it will read several degrees high.
**Setpoint does not survive a reboot** (defaults to 68 °F). Persisting it
would mean an NVS write, and a flash write while the panel is scanning faults
the bounce-buffer ISR — see Memory notes. Storing it in an HA `input_number`
would work and needs no flash.
## 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.
### Wind, and the compass
The station's anemometer was offline 2026-07-27 to 2026-08-07 and is live
again. It has its own column now: a dial whose needle points at the direction
the wind is coming **from** (weather-vane convention, matching the cardinal
label beneath it), the speed in mph, and an arrow for the 5-minute trend.
Three things worth knowing:
- **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. It snaps once a minute and the dirty area is just the dial.
- **Below 0.5 mph it shows "Calm" and draws no needle.** A vane that is not
being pushed reports whatever it last settled on — on this station that is a
steady 0°, which would otherwise render as a confident needle pointing due
north.
- **The trend is computed locally, not taken from HA's 10-minute average.**
`HA_TREND_CH_WIND` uses 1-minute buckets over a 5-minute window, which is as
fine as a 60-second poll can support. The threshold is 1.5 mph — wind is
noisy and a twitchy arrow is worse than none.
### Two indoor sensors
`temperature_4` is **downstairs** and `inside_temperature` is **upstairs** —
the names come from the Ambient Weather console, not the house, so they read
backwards. 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.
`HA_ENT_CONTROL_TEMP` in `ha_config.h` is the single place that choice is
made; the big card in `ui_manager.c` should move with it.
### Fallback### 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.
### UART1-OUT plug — the only free GPIO on this board
Nearly every pin is spoken for: 20 on the RGB bus, 2 on I2C, IO4/5/6 on
I2S+SD, IO8 buzzer, IO43/44 the console. The UART1-OUT plug (PH2.0-4P) is
what's left, and it is where the furnace relays connect.
| Wire | Pin | |
|------|-----|---|
| black | GND | |
| red | 3.3 V | 1 A budget; two relay coils ≈ 260 mA |
| white | **IO20** | measured, not documented by Elecrow |
| yellow | **IO19** | measured, not documented by Elecrow |
Elecrow publishes the GPIO numbers but **not the pin order**, so the
white/yellow mapping above was measured on this board (2026-08-05) by driving
each pin at a distinct blink rate and metering the plug. Static levels cannot
work for this: a pin held high is indistinguishable from the 3.3 V rail, and
one held low from GND.
Both pins were also confirmed electrically free — each follows an internal
pulldown to 0 and pullup to 1, so nothing else is driving them.
> **IO19/IO20 are shared** with the onboard I2S mic (MIC_CLK/MIC_SD) and are
> the ESP32-S3's native USB D−/D+. DIP switches select the mic away:
> S1=0 S0=1 is *wireless module* mode, which leaves these pins to us. Native
> USB is unused — the board flashes over UART0 through a CH340
> (`/dev/ttyUSB0`), so `CONFIG_USJ_ENABLE_USB_SERIAL_JTAG` and
> `CONFIG_ESP_PHY_ENABLE_USB` can both be turned off to release the pins
> fully.
## 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 **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
two buffers), with `bb_invalidate_cache` enabled.
That flag matters more than it looks. 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. That is
the real reason this board is so sensitive to WiFi activity 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 ever draws into the other one. This was 16 for a long time because 20 "fails to allocate once
WiFi is up" — which was true when `display_init()` ran after
`wifi_init_sta()`. The two-phase split changed that: `display_alloc()` now
takes these buffers from a virgin heap with ~200KB free, long before WiFi has
fragmented it. Bigger chunks give the refill ISR more time to fetch the next
one before the LCD needs it, which attacks the artefact mechanism directly.
That leaves ~24KB of internal heap once everything is up. Watch the
"Free internal heap" log line — if it approaches single digits, come back
down to 18 or 16 scanlines.
### 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:
```bash
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:
```bash
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
- **Setting a style property always invalidates, even to an identical value.**
`lv_obj_set_local_style_prop()` calls `lv_obj_refresh_style()`
unconditionally — there is no equality short-circuit, unlike the one you
might assume by analogy with `label_set_if_changed()`. A periodic update
that restyles widgets "harmlessly" is a periodic full repaint of them.
This cost a round of debugging: `ui_update_thermostat()` ran at 1Hz writing
seven styles, which turned the left-edge artefacts from occasional into
permanent. Every update path here is now change-guarded.
- **Do not repaint to fix artefacts.** It is tempting, because LVGL never
reinvalidates pixels it does not know are wrong. But the corruption scales
with how much is written, so a full-screen invalidate is 768KB of exactly
the PSRAM contention that causes it. Two attempts at this (post-poll and a
post-interaction "repair" timer) both made it visibly worse and were
removed. Redraw *less*, not more.
- **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()`.
- **Display bring-up is two-phase, and both orderings are load-bearing.**
`display_alloc()` runs *before* WiFi: the bounce buffers need two contiguous
25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments the heap
within seconds — observed as a boot-loop with 84KB free and no block big
enough. `display_start()` runs *after* WiFi: once scan-out begins, the
bounce refill ISR reads PSRAM, which shares the cache with flash, and an
NVS/PHY write would fault it with *"Cache disabled but cached memory region
accessed"*. Allocate early, scan late.
## 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_start()` and `CONFIG_ESP_WIFI_NVS_ENABLED` is off to keep the
WiFi stack from writing later. Static buffers beware: `.bss` lives in the
same internal RAM the bounce buffers come from, with only ~33KB of slack
after the panel is up — a few KB of new static array is enough to matter
(the trend ring is deliberately time-bucketed down to ~720 bytes for this
reason).