New panel design

This commit is contained in:
matt committed 2026-08-04 11:55:02 -07:00
1 parent face2432e4
commit b5d6b4a897
30 files changed
+4540 -171

No files matched your search

+104 -8
View File
@@ -11,9 +11,10 @@ ESP-IDF firmware for the **Elecrow CrowPanel 7" Advance** (ESP32-S3-WROOM-1-N16R
> **Advance**. Building it against Basic documentation yields a black screen
> and a silent I2C bus.
Currently displays outdoor conditions from an Ambient Weather personal weather
station. Thermostat control (setpoint, HVAC relays, scheduling) is not
implemented yet.
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
@@ -24,16 +25,32 @@ implemented yet.
| `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` | Ambient Weather REST client |
| `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 + Ambient Weather keys
# edit main/app_config.h: WiFi credentials + HA_TOKEN
```
`main/app_config.h` is gitignored — it holds your WiFi password and API keys.
`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
@@ -45,6 +62,50 @@ 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)
@@ -109,12 +170,47 @@ 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. The touch readout polls the input device from an
`lv_timer` instead.
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()`.