250 lines
11 KiB
Markdown
250 lines
11 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/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`).
|
||
|
||
## 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:
|
||
|
||
```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.
|
||
- **`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.
|