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>
350 lines
16 KiB
Markdown
350 lines
16 KiB
Markdown
# 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.
|
||
|
||
### 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.
|
||
|
||
### 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 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:
|
||
|
||
```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
|
||
|
||
- **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).
|