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
+4538 -169

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ build/
managed_components/
dependencies.lock
# Desktop simulator build output (sim/ sources themselves ARE tracked)
sim/build/
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
sdkconfig
sdkconfig.old
+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()`.
+23 -1
View File
@@ -1,5 +1,20 @@
# TODO
## Not yet verified on hardware
The Home Assistant rewrite, the new dashboard and the desktop simulator all
build clean, and the HA request/parse/render path was verified end to end
against the live instance **in the simulator**. None of it has run on the
panel yet. Specifically unconfirmed on device:
1. Touch actually toggling a light (the service calls were verified against HA
directly, and idempotently — `turn_off` on already-off entities).
2. Whether the shorter pause window (~85ms LAN HTTP, no TLS) makes the
left-edge artefacts rarer, as item 1 below predicts.
3. That the six light tiles are comfortable finger targets at 125px. This is
the thing most likely to need another pass, and the simulator's mouse
cannot answer it.
## Done
The six items from the 2026-08-03 review were completed in `d4b2ab7`
@@ -51,7 +66,14 @@ touch-to-wake is a natural small feature. Note the STC8H1K28 shares the I2C
bus with the GT911, so brightness writes must stay in task context (never an
ISR) and will contend with touch polls on the same bus.
### 4. The actual thermostat
### 4. The wind sensors are dead (hardware, not software)
The station anemometer has read `unavailable` since 2026-07-27 — wind speed,
gust, direction and feels-like. The WIND tile shows `--` by design and will
keep doing so until the hardware is fixed. Nothing to change in the firmware;
noted so it is not re-diagnosed as a parsing bug. See README "Home Assistant".
### 5. The actual thermostat
Setpoint, HVAC relay control, scheduling and persistence are all still
unimplemented — this remains a weather display. Note a separate project
+2
View File
@@ -9,6 +9,8 @@ idf_component_register(
"ui/lvgl_port.c"
"ui/ui_manager.c"
"api/ambient_weather.c"
"api/ha_client.c"
"api/ha_data.c"
INCLUDE_DIRS
"."
"wifi"
+263
View File
@@ -0,0 +1,263 @@
/*
* Home Assistant REST transport. See ha_client.h for the contract and why
* this is plain HTTP.
*/
#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "app_config.h"
#include "ha_client.h"
#include "ha_template.h"
static const char *TAG = "ha_client";
#define HA_URL_MAX 128
#define HA_BODY_MAX 1024
/*
* The forecast reply is the big one: six days of conditions and temperatures,
* measured at ~1.1KB. The state template is ~400 bytes. 4KB leaves room for
* HA to grow a field without silently truncating.
*/
#define HA_RESP_MAX 4096
/* Local network, no TLS handshake, so these can be far tighter than the
* cloud client's. A LAN request that has not answered in 5s is not going to. */
#define HA_TIMEOUT_MS 5000
static char resp_buf[HA_RESP_MAX];
static size_t resp_len;
static bool resp_truncated;
/*
* Kept alive between polls. Re-creating the client each time re-opens the TCP
* connection, which is cheap on a LAN but not free, and this runs inside the
* window where rendering is paused.
*/
static esp_http_client_handle_t client;
/*
* Long-lived HA tokens are JWTs -- ~180-200 characters, not the 30-40 an
* "API key" suggests. 320 leaves room for "Bearer " plus a token noticeably
* longer than the ones HA issues today.
*/
static char auth_header[320];
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
switch (evt->event_id) {
case HTTP_EVENT_ON_DATA:
/* Responses are chunked, so Content-Length is often -1; accumulate
* into a fixed buffer rather than sizing an allocation from it. */
if (resp_len + evt->data_len < sizeof(resp_buf)) {
memcpy(resp_buf + resp_len, evt->data, evt->data_len);
resp_len += evt->data_len;
} else if (!resp_truncated) {
resp_truncated = true; /* once per response, not per chunk */
ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", HA_RESP_MAX);
}
break;
default:
break;
}
return ESP_OK;
}
static void client_drop(void)
{
if (client != NULL) {
esp_http_client_cleanup(client);
client = NULL;
}
}
esp_err_t ha_client_init(void)
{
if (HA_HOST[0] == '\0' || HA_TOKEN[0] == '\0') {
ESP_LOGW(TAG, "HA_HOST or HA_TOKEN not set in main/app_config.h -- "
"Home Assistant will not be polled");
return ESP_ERR_INVALID_STATE;
}
const int n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", HA_TOKEN);
if (n < 0 || n >= (int)sizeof(auth_header)) {
ESP_LOGE(TAG, "Token too long for the auth header buffer");
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
/*
* One POST. `path` and `body` are borrowed; the reply lands in resp_buf.
*
* The client is rebuilt whenever the URL changes, because the three endpoints
* this talks to (template, service call, forecast) are different paths on the
* same host and esp_http_client keeps the connection per-handle.
*/
static esp_err_t ha_post(const char *path, const char *body)
{
char url[HA_URL_MAX];
const int n = snprintf(url, sizeof(url), "http://%s:%d%s", HA_HOST, HA_PORT, path);
if (n < 0 || n >= (int)sizeof(url)) {
ESP_LOGE(TAG, "URL truncated for path %s", path);
return ESP_ERR_INVALID_SIZE;
}
if (client == NULL) {
const esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = HA_TIMEOUT_MS,
.event_handler = http_event_handler,
.keep_alive_enable = true,
};
client = esp_http_client_init(&config);
if (client == NULL) {
return ESP_ERR_NO_MEM;
}
esp_http_client_set_header(client, "Authorization", auth_header);
esp_http_client_set_header(client, "Content-Type", "application/json");
} else if (esp_http_client_set_url(client, url) != ESP_OK) {
client_drop();
return ESP_FAIL;
}
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_post_field(client, body, (int)strlen(body));
resp_len = 0;
resp_truncated = false;
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "POST %s failed: %s", path, esp_err_to_name(err));
client_drop(); /* a half-open connection would fail every later poll */
return err;
}
const int status = esp_http_client_get_status_code(client);
if (status != 200) {
ESP_LOGE(TAG, "HTTP %d from %s", status, path);
if (status == 401) {
ESP_LOGE(TAG, "Token rejected -- check HA_TOKEN in app_config.h");
}
client_drop();
return ESP_FAIL;
}
if (resp_len == 0) {
/* Service calls legitimately return an empty body. */
resp_buf[0] = '\0';
return ESP_OK;
}
resp_buf[resp_len] = '\0';
return ESP_OK;
}
esp_err_t ha_fetch_state(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/*
* The template is JSON-escaped by cJSON rather than by hand: it is full of
* quotes and braces, and getting the escaping subtly wrong yields a 400
* that looks like a template bug.
*/
cJSON *req = cJSON_CreateObject();
if (req == NULL) {
return ESP_ERR_NO_MEM;
}
if (cJSON_AddStringToObject(req, "template", HA_STATE_TEMPLATE) == NULL) {
cJSON_Delete(req);
return ESP_ERR_NO_MEM;
}
char *body = cJSON_PrintUnformatted(req);
cJSON_Delete(req);
if (body == NULL) {
return ESP_ERR_NO_MEM;
}
const esp_err_t err = ha_post("/api/template", body);
cJSON_free(body);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_json(resp_buf, data)) {
ESP_LOGE(TAG, "State response was not valid JSON");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_fetch_forecast(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* return_response is required: without it HA runs the service and returns
* nothing, which looks like an empty forecast rather than a mistake. */
const esp_err_t err = ha_post(
"/api/services/weather/get_forecasts?return_response", HA_FORECAST_BODY);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_forecast_json(resp_buf, data)) {
ESP_LOGE(TAG, "Forecast response could not be parsed");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_light_command(int index, bool on, int16_t brightness)
{
if (index < 0 || index >= HA_LIGHT_COUNT) {
return ESP_ERR_INVALID_ARG;
}
const ha_light_cfg_t *cfg = &HA_LIGHTS[index];
const char *domain = ha_service_domain(cfg->entity_id);
char path[HA_URL_MAX];
int n = snprintf(path, sizeof(path), "/api/services/%s/turn_%s",
domain, on ? "on" : "off");
if (n < 0 || n >= (int)sizeof(path)) {
return ESP_ERR_INVALID_SIZE;
}
char body[HA_BODY_MAX];
if (on && brightness >= 0 && cfg->dimmable) {
n = snprintf(body, sizeof(body),
"{\"entity_id\":\"%s\",\"brightness\":%d}",
cfg->entity_id, brightness);
} else {
/* No brightness key: turning on without one makes HA restore the
* light's previous level, which is what a user expects from a tap. */
n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\"}", cfg->entity_id);
}
if (n < 0 || n >= (int)sizeof(body)) {
return ESP_ERR_INVALID_SIZE;
}
ESP_LOGI(TAG, "%s -> %s%s", cfg->entity_id, on ? "on" : "off",
(on && brightness >= 0 && cfg->dimmable) ? " (with brightness)" : "");
return ha_post(path, body);
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Home Assistant REST transport (device only).
*
* Everything ESP-IDF-specific about talking to HA lives here; parsing and
* presentation are in ha_data.c, which the desktop simulator also compiles.
*
* Plain HTTP on the LAN, deliberately. The Ambient Weather cloud client needs
* TLS and pays a multi-second handshake for it; this talks to a box on the
* same switch, and the token is not worth a handshake inside the window where
* the UI is frozen. Do not point this at an https:// HA instance without
* revisiting that trade-off.
*/
#ifndef HA_CLIENT_H
#define HA_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#include "ha_data.h"
/* Validates configuration. Does not open a connection. */
esp_err_t ha_client_init(void);
/*
* One request for every sensor, light and sun time (~400 bytes). Leaves
* data->forecast alone -- see ha_fetch_forecast().
*
* Blocks for the round trip, so callers pause LVGL around it.
*/
esp_err_t ha_fetch_state(ha_data_t *data);
/*
* Fills data->forecast. A separate, slower poll: the forecast is a service
* call rather than a template render, and it changes hourly at most.
*/
esp_err_t ha_fetch_forecast(ha_data_t *data);
/*
* Turn a light on or off. `index` indexes HA_LIGHTS; `brightness` is 0-255,
* or -1 to leave the level alone (and always for switch-domain entities).
*
* Blocks. Must not be called from the LVGL task -- see ui_set_light_cb().
*/
esp_err_t ha_light_command(int index, bool on, int16_t brightness);
#endif
+76
View File
@@ -0,0 +1,76 @@
/*
* Home Assistant entity IDs for this installation.
*
* Entity IDs are not secrets, so unlike the token in app_config.h they are
* tracked. They are also not stable: renaming a device or reinstalling an
* integration changes them, and a wrong ID fails silently as "unavailable"
* rather than as an error. Re-check with tools/ha_discover.py after touching
* anything in HA.
*/
#ifndef HA_CONFIG_H
#define HA_CONFIG_H
/* --- Weather station (Ambient Weather, exposed locally through HA) ------- */
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
/*
* The anemometer has been offline since 2026-07-27: wind speed, gust and
* direction all read "unavailable". The display shows blanks for these by
* design -- no substituting forecast wind for station wind.
*
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
* it still reports its last value from 2026-07-28 and would render as a
* plausible live number. Anything using it needs a staleness check first.
*/
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
#define HA_ENT_WIND_DIR "sensor.moonlight_beach_wind_direction"
/* --- Forecast and sun ---------------------------------------------------- */
/* met.no via HA. Supplies the textual condition and the daily forecast. */
#define HA_ENT_WEATHER "weather.forecast_home"
#define HA_ENT_SUN "sun.sun"
/* --- Lights -------------------------------------------------------------- */
/*
* The X-macro is load-bearing, not decoration.
*
* The JSON template asks HA for these entities as an ordered array, and the
* firmware reads the reply back by index. Two hand-maintained lists would
* drift the first time one is reordered, and the failure mode is silent: the
* Dining Room tile would just start controlling the Blue Lamp. Generating the
* template's entity list and the C table from one source makes that
* impossible.
*
* X(entity_id, label, dimmable)
*
* `dimmable` is false for the patio, which is a TP-Link smart plug in the
* switch domain -- on/off only, and it needs switch.turn_on rather than
* light.turn_on. See ha_service_domain().
*
* Note light.main_room is a GROUP containing blue_lamp, brown_lamp and
* landing_chandelier. Toggling it changes three of the other tiles; that is
* expected, and the UI reflects it on the next poll.
*/
/* Labels must fit one line of Montserrat 16 in a ~125px tile -- roughly 11
* characters. Longer ones are truncated with an ellipsis, which reads as a
* mistake. "Dining Room" is one character too wide, hence "Dining". */
#define HA_LIGHT_LIST(X) \
X("light.main_room", "Main Room", true) \
X("light.dining_room", "Dining", true) \
X("light.blue_lamp", "Blue Lamp", true) \
X("light.brown_lamp", "Brown Lamp", true) \
X("light.landing_chandelier", "Chandelier", true) \
X("switch.tp_link_smart_plug_f048_outdoor_hanging_lights", "Patio", false)
#endif
+322
View File
@@ -0,0 +1,322 @@
/*
* Parsing and presentation helpers for Home Assistant data.
*
* Deliberately free of ESP-IDF and network dependencies: this file is
* compiled into the desktop simulator as well as the firmware, so the layout
* work in sim/ exercises the same parser the device runs. Keep it that way --
* anything needing esp_* belongs in ha_client.c.
*/
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "ha_data.h"
/* Build the config table from the same list the JSON template is built from. */
#define HA_LIGHT_ENTRY_X(id, label, dimmable) { id, label, dimmable },
const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = {
HA_LIGHT_LIST(HA_LIGHT_ENTRY_X)
};
/* ------------------------------------------------------------------ values */
/*
* A JSON null (or a missing key) means the sensor is unavailable and the
* field stays invalid. Note cJSON_IsNumber() is false for null, so this
* handles both cases without a separate null check.
*/
static void take_number(const cJSON *root, const char *key, ha_value_t *out)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsNumber(item)) {
out->value = (float)item->valuedouble;
out->valid = true;
}
}
static void take_string(const cJSON *root, const char *key, char *out, size_t len)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsString(item) && item->valuestring != NULL) {
snprintf(out, len, "%s", item->valuestring);
}
}
/* ------------------------------------------------------------------ lights */
static ha_light_state_t light_state_from_string(const char *s)
{
if (s == NULL) {
return HA_LIGHT_UNAVAILABLE;
}
if (strcmp(s, "on") == 0) {
return HA_LIGHT_ON;
}
if (strcmp(s, "off") == 0) {
return HA_LIGHT_OFF;
}
/* "unavailable", "unknown", or anything unexpected. Treated the same:
* the tile greys out rather than claiming the light is off. */
return HA_LIGHT_UNAVAILABLE;
}
static void parse_lights(const cJSON *root, ha_data_t *out)
{
const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights");
if (!cJSON_IsArray(lights)) {
return;
}
/*
* Index-aligned with HA_LIGHTS because the template iterates the same
* X-macro list. If HA returns a different count, something is out of sync
* between the template and the table -- take what lines up rather than
* reading past either array.
*/
const int n = cJSON_GetArraySize(lights);
const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT;
for (int i = 0; i < count; i++) {
const cJSON *entry = cJSON_GetArrayItem(lights, i);
if (!cJSON_IsObject(entry)) {
continue;
}
const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s");
out->lights[i].state = light_state_from_string(
cJSON_IsString(state) ? state->valuestring : NULL);
const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b");
out->lights[i].brightness = cJSON_IsNumber(bright)
? (int16_t)bright->valuedouble
: -1;
}
}
/* -------------------------------------------------------------- state JSON */
bool ha_parse_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
/*
* Preserve the forecast across the wipe. It arrives from a different,
* slower request; zeroing the whole struct here would blank the forecast
* strip on every sensor poll.
*/
ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS];
memcpy(saved_forecast, out->forecast, sizeof(saved_forecast));
const uint8_t saved_count = out->forecast_count;
memset(out, 0, sizeof(*out));
memcpy(out->forecast, saved_forecast, sizeof(saved_forecast));
out->forecast_count = saved_count;
take_number(root, "out_t", &out->outdoor_temp_f);
take_number(root, "out_h", &out->outdoor_humidity);
take_number(root, "in_t", &out->indoor_temp_f);
take_number(root, "in_h", &out->indoor_humidity);
take_number(root, "dew", &out->dew_point_f);
take_number(root, "press", &out->pressure_inhg);
take_number(root, "rain_d", &out->rain_today_in);
take_number(root, "rain_r", &out->rain_rate_inhr);
take_number(root, "wind", &out->wind_mph);
take_number(root, "gust", &out->gust_mph);
take_number(root, "wdir", &out->wind_dir_deg);
take_string(root, "cond", out->condition, sizeof(out->condition));
take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise));
take_string(root, "sunset", out->sunset, sizeof(out->sunset));
take_string(root, "clock", out->clock, sizeof(out->clock));
take_string(root, "date", out->date, sizeof(out->date));
out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up"));
parse_lights(root, out);
cJSON_Delete(root);
return true;
}
/* ----------------------------------------------------------- forecast JSON */
/*
* Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence.
*
* The device has no clock, so the day name cannot come from localtime(). The
* forecast timestamps are UTC; HA returns them at 19:00Z, which is midday
* locally, so the UTC date and the local date agree. That would stop being
* true for a timestamp near midnight UTC -- if HA ever changes the hour, this
* needs the offset applied first.
*/
static void weekday_name(const char *iso, char *out, size_t len)
{
int y = 0, m = 0, d = 0;
if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) {
snprintf(out, len, "--");
return;
}
/* Zeller treats January and February as months 13 and 14 of the year
* before. */
if (m < 3) {
m += 12;
y -= 1;
}
const int K = y % 100;
const int J = y / 100;
const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
/* Zeller's h: 0 = Saturday. */
static const char *const names[7] = {
"Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"
};
snprintf(out, len, "%s", names[h]);
}
bool ha_parse_forecast_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
bool ok = false;
/* { "service_response": { "<entity>": { "forecast": [ ... ] } } } */
const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response");
const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL;
const cJSON *list = cJSON_IsObject(entity)
? cJSON_GetObjectItemCaseSensitive(entity, "forecast")
: NULL;
if (cJSON_IsArray(list)) {
const int n = cJSON_GetArraySize(list);
const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS;
memset(out->forecast, 0, sizeof(out->forecast));
for (int i = 0; i < count; i++) {
const cJSON *day = cJSON_GetArrayItem(list, i);
if (!cJSON_IsObject(day)) {
continue;
}
ha_forecast_day_t *slot = &out->forecast[i];
const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime");
weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL,
slot->day, sizeof(slot->day));
take_string(day, "condition", slot->condition, sizeof(slot->condition));
take_number(day, "temperature", &slot->high_f);
take_number(day, "templow", &slot->low_f);
}
out->forecast_count = (uint8_t)count;
ok = true;
}
cJSON_Delete(root);
return ok;
}
/* ----------------------------------------------------------- presentation */
const char *ha_service_domain(const char *entity_id)
{
/* The patio plug lives in the switch domain; light.turn_on on a switch
* entity is accepted by HA and silently does nothing. */
if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) {
return "switch";
}
return "light";
}
typedef struct {
const char *slug;
const char *label;
const char *shrt;
} condition_name_t;
/* met.no condition slugs as HA reports them. `shrt` is for the forecast
* strip, where a column is about 150px wide. */
static const condition_name_t CONDITIONS[] = {
{ "clear-night", "Clear", "Clear" },
{ "cloudy", "Cloudy", "Cloudy" },
{ "exceptional", "Exceptional", "Severe" },
{ "fog", "Fog", "Fog" },
{ "hail", "Hail", "Hail" },
{ "lightning", "Thunderstorms", "Storm" },
{ "lightning-rainy", "Thunderstorms", "Storm" },
{ "partlycloudy", "Partly Cloudy", "Partly" },
{ "pouring", "Heavy Rain", "Heavy" },
{ "rainy", "Rain", "Rain" },
{ "snowy", "Snow", "Snow" },
{ "snowy-rainy", "Wintry Mix", "Wintry" },
{ "sunny", "Sunny", "Sunny" },
{ "windy", "Windy", "Windy" },
{ "windy-variant", "Windy", "Windy" },
};
static const condition_name_t *condition_lookup(const char *slug)
{
if (slug == NULL) {
return NULL;
}
for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) {
if (strcmp(CONDITIONS[i].slug, slug) == 0) {
return &CONDITIONS[i];
}
}
return NULL;
}
const char *ha_condition_label(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
/* Unrecognised slugs pass through rather than becoming "Unknown" -- if
* met.no adds a condition, seeing the raw slug on screen says what to
* add here. */
return (c != NULL) ? c->label : (slug != NULL ? slug : "--");
}
const char *ha_condition_short(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--");
}
const char *ha_wind_cardinal(float degrees)
{
static const char *const points[16] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
};
if (!isfinite(degrees)) {
return "--";
}
/* fmodf twice: a negative bearing would otherwise index out of bounds. */
float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f);
const int idx = (int)((d + 11.25f) / 22.5f) % 16;
return points[idx];
}
+136
View File
@@ -0,0 +1,136 @@
/*
* The data contract between "fetching" and "drawing".
*
* ha_data_t is plain data with no ESP-IDF or network dependency, and
* ha_parse_json() is a pure function. That is what lets the desktop simulator
* run the real UI: sim/ supplies a JSON snapshot or a LAN fetch, the firmware
* supplies esp_http_client, and both hand the identical struct to the
* identical drawing code.
*
* The UI layer must never see HTTP, cJSON or an entity ID -- only this.
*/
#ifndef HA_DATA_H
#define HA_DATA_H
#include <stdbool.h>
#include <stdint.h>
#include "ha_config.h"
/* Five days across the bottom strip. HA returns six; the sixth is dropped. */
#define HA_FORECAST_DAYS 5
/* Derived from HA_LIGHT_LIST so the two can never disagree. */
#define HA_LIGHT_COUNT_X(a, b, c) +1
#define HA_LIGHT_COUNT (0 HA_LIGHT_LIST(HA_LIGHT_COUNT_X))
/*
* A reading that may not exist.
*
* Sensors go unavailable routinely -- a dead anemometer, a flat battery, an
* integration reloading -- and a float alone cannot say so. Every consumer has
* to check `valid` before touching `value`; there is deliberately no sentinel
* to accidentally render as a real number.
*/
typedef struct {
float value;
bool valid;
} ha_value_t;
typedef enum {
HA_LIGHT_OFF = 0,
HA_LIGHT_ON,
HA_LIGHT_UNAVAILABLE, /* entity exists but HA cannot reach the device */
} ha_light_state_t;
typedef struct {
ha_light_state_t state;
/* 0-255 as HA reports it. Only meaningful when state is HA_LIGHT_ON and
* the entity is dimmable; -1 otherwise. */
int16_t brightness;
} ha_light_status_t;
typedef struct {
const char *entity_id;
const char *label;
bool dimmable;
} ha_light_cfg_t;
/* Generated from HA_LIGHT_LIST; index-aligned with ha_data_t.lights. */
extern const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT];
typedef struct {
char day[4]; /* "Mon" -- derived from the ISO date, see below */
char condition[24]; /* met.no condition slug, e.g. "partlycloudy" */
ha_value_t high_f;
ha_value_t low_f;
} ha_forecast_day_t;
typedef struct {
/* Weather station */
ha_value_t outdoor_temp_f;
ha_value_t outdoor_humidity;
ha_value_t indoor_temp_f;
ha_value_t indoor_humidity;
ha_value_t dew_point_f;
ha_value_t pressure_inhg;
ha_value_t rain_today_in;
ha_value_t rain_rate_inhr;
ha_value_t wind_mph;
ha_value_t gust_mph;
ha_value_t wind_dir_deg;
/* Forecast / sun. These strings arrive pre-formatted in local time --
* HA owns the timezone, so the device needs no clock and no SNTP. */
char condition[24];
char sunrise[12]; /* "6:05 AM" */
char sunset[12]; /* "7:45 PM" */
char clock[12]; /* "10:21 AM" */
char date[16]; /* "Tue Aug 4" */
bool sun_up;
ha_light_status_t lights[HA_LIGHT_COUNT];
ha_forecast_day_t forecast[HA_FORECAST_DAYS];
uint8_t forecast_count; /* 0 when no forecast has been fetched yet */
} ha_data_t;
/*
* Parse the reply to the state template into `out`.
*
* Zeroes `out` first, so a field missing from the response reads as invalid
* rather than keeping a previous poll's value. Returns false only when the
* body is not parseable JSON -- individual missing or unavailable sensors are
* normal and leave their ha_value_t invalid.
*
* Does not touch the forecast fields; those come from ha_parse_forecast_json()
* on a slower poll and would otherwise be wiped every minute.
*/
bool ha_parse_json(const char *json, ha_data_t *out);
/*
* Parse a weather/get_forecasts service response into out->forecast.
* Fills at most HA_FORECAST_DAYS entries and sets forecast_count.
*/
bool ha_parse_forecast_json(const char *json, ha_data_t *out);
/*
* "light" or "switch" for entity_id -- the HA service domain to call. The
* patio plug is a switch, and calling light.turn_on on it silently does
* nothing.
*/
const char *ha_service_domain(const char *entity_id);
/* Human-readable form of a met.no condition slug ("partlycloudy" ->
* "Partly Cloudy"). Returns the slug unchanged if unrecognised, so an
* unhandled condition shows up on screen as the thing to go add. */
const char *ha_condition_label(const char *slug);
/* As above, abbreviated for the forecast strip's narrow columns
* ("partlycloudy" -> "Partly"). */
const char *ha_condition_short(const char *slug);
/* Compass point for a bearing in degrees ("NNE"). */
const char *ha_wind_cardinal(float degrees);
#endif
+78
View File
@@ -0,0 +1,78 @@
/*
* The Jinja template POSTed to Home Assistant's /api/template.
*
* Why a template rather than plain REST calls:
*
* - One request instead of a dozen. /api/states returns all ~204 entities
* (far too much to buffer); fetching entities one at a time would mean a
* dozen round-trips inside the window where the UI is frozen. This is a
* single ~430 byte reply, measured at ~85ms on the LAN.
* - HA formats the times. sunrise/sunset/clock/date come back as local-time
* strings, so the device needs neither SNTP nor a timezone database, and
* DST is somebody else's problem. The board's clock starts at the epoch
* and that no longer matters.
* - Unavailable sensors arrive as JSON null, not a sentinel number. A
* sentinel would be indistinguishable from a real reading -- -1 mph of
* wind is impossible, but -1 degF of dew point is not.
*
* The reply is compact JSON; see ha_parse_json().
*/
#ifndef HA_TEMPLATE_H
#define HA_TEMPLATE_H
#include "ha_config.h"
/* Emits the entity list for the lights loop, in HA_LIGHT_LIST order. The
* trailing comma is deliberate -- Jinja accepts it, and it avoids a
* last-element special case. */
#define HA_TMPL_LIGHT_X(id, label, dimmable) "'" id "',"
/*
* num() -- a state as a number, or null if it is not one ("unavailable",
* "unknown", or a non-numeric state).
* attrnum() -- ditto for an attribute; brightness is None when a light is off.
*/
#define HA_STATE_TEMPLATE \
"{%- macro num(e) -%}{%- set v = states(e) -%}" \
"{{ v | float if is_number(v) else 'null' }}{%- endmacro -%}" \
"{%- macro attrnum(e, a) -%}{%- set v = state_attr(e, a) -%}" \
"{{ v if v is number else 'null' }}{%- endmacro -%}" \
"{" \
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
"\"rain_r\":" "{{ num('" HA_ENT_RAIN_RATE "') }}," \
"\"wind\":" "{{ num('" HA_ENT_WIND_SPEED "') }}," \
"\"gust\":" "{{ num('" HA_ENT_WIND_GUST "') }}," \
"\"wdir\":" "{{ num('" HA_ENT_WIND_DIR "') }}," \
"\"cond\":\"" "{{ states('" HA_ENT_WEATHER "') }}\"," \
"\"sunrise\":\"{{ (state_attr('" HA_ENT_SUN "','next_rising')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sunset\":\"{{ (state_attr('" HA_ENT_SUN "','next_setting')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sun_up\":{{ (states('" HA_ENT_SUN "') == 'above_horizon') | lower }}," \
"\"clock\":\"{{ now().strftime('%-I:%M %p') }}\"," \
"\"date\":\"{{ now().strftime('%a %b %-d') }}\"," \
"\"lights\":[" \
"{%- for e in [" HA_LIGHT_LIST(HA_TMPL_LIGHT_X) "] -%}" \
"{\"s\":\"{{ states(e) }}\",\"b\":{{ attrnum(e,'brightness') }}}" \
"{{ \",\" if not loop.last }}" \
"{%- endfor -%}" \
"]}"
/*
* Forecast body for POST /api/services/weather/get_forecasts?return_response.
*
* The forecast is deliberately NOT part of the template above: HA removed the
* `forecast` attribute from weather entities in 2024.4, and Jinja templates
* cannot call services. It also changes far more slowly than the sensors, so
* it is polled on its own longer interval.
*/
#define HA_FORECAST_BODY \
"{\"entity_id\":\"" HA_ENT_WEATHER "\",\"type\":\"daily\"}"
#endif
+30 -6
View File
@@ -5,15 +5,39 @@
#ifndef APP_CONFIG_H
#define APP_CONFIG_H
/* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
/* --- Home Assistant (primary data source) --------------------------------
*
* Plain HTTP on the LAN, so there is no certificate handling and no TLS
* handshake inside the window where rendering is paused. HA_TOKEN is a
* long-lived access token: in Home Assistant, click your user (bottom left)
* -> Security -> Long-lived access tokens -> Create token.
*
* Entity IDs are NOT here -- they are not secrets, so they live in
* main/api/ha_config.h. Re-check them with tools/ha_discover.py.
*/
#define HA_HOST "192.168.1.134"
#define HA_PORT 8123
#define HA_TOKEN ""
#define HA_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* --- Ambient Weather (fallback only) -------------------------------------
*
* Used only when Home Assistant cannot be reached, and then only for outdoor
* temperature, humidity and wind -- indoor, sun, forecast and lights have no
* cloud equivalent. Leaving these blank is fine; it just means no fallback.
*
* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF"
*/
#define AMBIENT_WEATHER_API_KEY ""
#define AMBIENT_WEATHER_APP_KEY ""
#define AMBIENT_WEATHER_DEVICE_ID ""
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* The fallback client's own poll interval name. */
#define WEATHER_UPDATE_INTERVAL_MS HA_UPDATE_INTERVAL_MS
#endif
+158 -54
View File
@@ -4,12 +4,15 @@
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "ambient_weather.h"
#include "app_config.h"
#include "backlight.h"
#include "display_rgb.h"
#include "ha_client.h"
#include "ha_data.h"
#include "i2c_bus.h"
#include "lvgl_port.h"
#include "touch_gt911.h"
@@ -19,99 +22,187 @@
static const char *TAG = "main";
/*
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
* is why the handshake fits at all. Re-check the high-water-mark log if more
* work lands in this task.
* 6144 was measured with the old cloud client, whose TLS handshake was the
* deepest this task ever got. The HA path is plain HTTP and shallower, but
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
* headroom is kept. Re-check the high-water-mark log if more work lands here.
*/
#define WEATHER_TASK_STACK 6144
#define WEATHER_TASK_PRIO 3
#define WEATHER_FIRST_DELAY_MS 3000
#define HA_TASK_STACK 6144
#define HA_TASK_PRIO 3
#define HA_FIRST_DELAY_MS 3000
/* The forecast changes hourly at most and costs a service call, so it is
* polled far less often than the sensors. */
#define FORECAST_EVERY_N_POLLS 30
/* Consecutive failed polls before the displayed reading is treated as stale. */
#define WEATHER_STALE_AFTER_POLLS 2
#define STALE_AFTER_POLLS 2
static void weather_update_task(void *arg)
/* Depth 8: a burst of taps while the network is slow should queue rather than
* be dropped, but a user cannot meaningfully get more than a few ahead. */
#define CMD_QUEUE_DEPTH 8
typedef struct {
int index;
bool on;
int16_t brightness;
} light_cmd_t;
static QueueHandle_t cmd_queue;
/* The single copy of what is on screen. Only the HA task touches it. */
static ha_data_t ha_data;
/*
* Runs on the LVGL task with the LVGL lock held, so it must not block.
* Queueing hands the HTTP request to the HA task; a request from here would
* freeze rendering for the duration and deadlock anything waiting on the lock.
*/
static void light_cmd_from_ui(int index, bool on, int16_t brightness)
{
const light_cmd_t cmd = { .index = index, .on = on, .brightness = brightness };
if (cmd_queue == NULL || xQueueSend(cmd_queue, &cmd, 0) != pdTRUE) {
ESP_LOGW(TAG, "Light command queue full, dropping command for index %d", index);
}
}
/*
* Fall back to the Ambient Weather cloud API when HA is unreachable.
*
* Only fills the outdoor readings -- indoor, sun, lights and forecast have no
* cloud equivalent and stay as they were. That is deliberate: a partial screen
* of live outdoor data beats a blank one, and the status line says the panel
* is running on the fallback.
*/
static bool fill_from_cloud(ha_data_t *data)
{
weather_data_t w;
if (weather_update(&w) != ESP_OK) {
return false;
}
data->outdoor_temp_f = (ha_value_t){ w.temp_f, w.has_temp };
data->outdoor_humidity = (ha_value_t){ w.humidity, w.has_humidity };
data->wind_mph = (ha_value_t){ w.wind_mph, w.has_wind };
return w.has_temp;
}
static void ha_task(void *arg)
{
weather_data_t weather;
char line[96];
/*
* Age is tracked in ticks, not from weather_data_t.last_update. That field
* comes from time(NULL), and with no SNTP client the system clock starts
* at the epoch -- an age derived from it would be nonsense. Tick
* Age is tracked in ticks, not wall time: with no SNTP the system clock
* starts at the epoch. (The clock shown on screen comes from HA, which is
* why the panel can display the time without knowing it.) Tick
* subtraction is also wrap-safe.
*/
TickType_t last_success = 0;
int consecutive_failures = 0;
bool have_reading = false;
bool stack_logged = false;
uint32_t poll_count = 0;
vTaskDelay(pdMS_TO_TICKS(WEATHER_FIRST_DELAY_MS));
vTaskDelay(pdMS_TO_TICKS(HA_FIRST_DELAY_MS));
/*
* Zero on the first pass so the dashboard populates immediately after
* boot. The wait below is what paces the loop, and it sits at the top --
* so a full-interval timeout here would leave the panel showing dashes
* for HA_UPDATE_INTERVAL_MS before the first poll ever ran. Measured on
* hardware as a 63-second blank screen.
*/
TickType_t wait_ticks = 0;
while (1) {
/*
* Wait for a light command, or time out and poll. A command wakes the
* task immediately so a tap feels instant instead of waiting out the
* poll interval.
*/
light_cmd_t cmd;
const bool have_cmd = (cmd_queue != NULL) &&
(xQueueReceive(cmd_queue, &cmd, wait_ticks) == pdTRUE);
wait_ticks = pdMS_TO_TICKS(HA_UPDATE_INTERVAL_MS);
if (!wifi_is_connected()) {
ui_set_status("WiFi: reconnecting...");
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
continue;
}
/*
* Freeze rendering for the duration of the fetch. The TLS session,
* the LVGL redraw and the flush into the PSRAM framebuffer all
* compete with the LCD's own DMA out of that same PSRAM; letting them
* overlap is what produces visible artefacts. The panel keeps
* scanning the last completed frame, so the screen simply holds still.
* Freeze rendering for the duration of the network work. The redraw
* and the flush into the PSRAM framebuffer compete with the LCD's own
* DMA out of that same PSRAM; letting them overlap is what produces
* visible artefacts. The panel keeps scanning the last completed
* frame, so the screen simply holds still.
*/
lvgl_port_pause();
const esp_err_t err = weather_update(&weather);
if (have_cmd) {
const esp_err_t cmd_err = ha_light_command(cmd.index, cmd.on, cmd.brightness);
if (cmd_err != ESP_OK) {
ESP_LOGW(TAG, "Light command failed: %s", esp_err_to_name(cmd_err));
}
}
esp_err_t err = ha_fetch_state(&ha_data);
/* The forecast is a second request, so only fetch it when the first
* one proved HA is reachable. */
if (err == ESP_OK &&
(ha_data.forecast_count == 0 || (poll_count % FORECAST_EVERY_N_POLLS) == 0)) {
if (ha_fetch_forecast(&ha_data) != ESP_OK) {
ESP_LOGW(TAG, "Forecast fetch failed; keeping previous days");
}
}
bool on_fallback = false;
if (err != ESP_OK) {
on_fallback = fill_from_cloud(&ha_data);
}
lvgl_port_resume();
if (err == ESP_OK) {
poll_count++;
if (err == ESP_OK || on_fallback) {
last_success = xTaskGetTickCount();
consecutive_failures = 0;
have_reading = true;
weather_get_string(&weather, line, sizeof(line));
ESP_LOGI(TAG, "Weather: %s", line);
char detail[64];
snprintf(detail, sizeof(detail), "%.0f%% humidity",
weather.has_humidity ? weather.humidity : 0.0f);
/* lroundf, not +0.5f-and-truncate: the latter rounds toward zero
* below 0°F, turning -10.6 into -10. */
ui_update_weather((int16_t)lroundf(weather.temp_f),
weather.has_humidity ? detail : "Outdoor");
ui_set_weather_stale(false);
ui_update(&ha_data);
ui_set_stale(false);
if (on_fallback) {
ui_set_status("Home Assistant unreachable - cloud weather only");
} else {
wifi_get_status(line, sizeof(line));
ui_set_status(line);
}
/* One-shot, after a fetch that included a TLS handshake -- that is
* the deepest this task's stack ever gets. */
if (!stack_logged) {
stack_logged = true;
ESP_LOGI(TAG, "weather task stack headroom: %u bytes",
ESP_LOGI(TAG, "HA task stack headroom: %u bytes",
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
}
} else {
consecutive_failures++;
ESP_LOGW(TAG, "Weather update failed (%d consecutive)", consecutive_failures);
ESP_LOGW(TAG, "Update failed (%d consecutive)", consecutive_failures);
if (have_reading && consecutive_failures >= WEATHER_STALE_AFTER_POLLS) {
if (have_reading && consecutive_failures >= STALE_AFTER_POLLS) {
const uint32_t age_min =
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
ui_set_weather_stale(true);
ui_set_stale(true);
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
ui_set_status(line);
} else {
ui_set_status("Weather update failed");
ui_set_status("Update failed");
}
}
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
}
}
@@ -130,7 +221,7 @@ void app_main(void)
*/
const esp_err_t wifi_err = wifi_init_sta();
if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "WiFi not up yet -- weather starts when it connects");
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
}
/* LVGL core must exist before the display and touch drivers register
@@ -150,7 +241,8 @@ void app_main(void)
ESP_ERROR_CHECK(display_init());
if (touch_init() != ESP_OK) {
/* A dead touch panel should not stop the display from working. */
/* A dead touch panel should not stop the display from working -- but
* it does mean the light tiles are unusable. */
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
}
@@ -166,24 +258,36 @@ void app_main(void)
wifi_get_status(status, sizeof(status));
ui_set_status(status);
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
/* The event handler keeps retrying in the background; the weather
* task polls wifi_is_connected(), so start it anyway rather than
* permanently disabling weather because boot raced a slow AP. */
/* The event handler keeps retrying in the background; the HA task
* polls wifi_is_connected(), so start it anyway rather than
* permanently disabling updates because boot raced a slow AP. */
ui_set_status("WiFi: connecting...");
} else {
/* No SSID configured (or setup failed outright) -- nothing will ever
* connect, so there is no point starting the weather task. */
* connect, so there is no point starting the task. */
ui_set_status("WiFi: not configured");
return;
}
if (weather_init() != ESP_OK) {
ui_set_status("Weather: no API credentials");
if (ha_client_init() != ESP_OK) {
ui_set_status("Home Assistant: not configured");
return;
}
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
WEATHER_TASK_PRIO, NULL);
/* Optional: only used when HA cannot be reached. A missing cloud API key
* is not fatal, it just means there is no fallback. */
if (weather_init() != ESP_OK) {
ESP_LOGW(TAG, "Ambient Weather fallback unavailable (no credentials)");
}
cmd_queue = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(light_cmd_t));
if (cmd_queue == NULL) {
ESP_LOGE(TAG, "Could not create the light command queue");
return;
}
ui_set_light_cb(light_cmd_from_ui);
xTaskCreate(ha_task, "ha", HA_TASK_STACK, NULL, HA_TASK_PRIO, NULL);
ESP_LOGI(TAG, "System ready");
}
+602 -96
View File
@@ -1,3 +1,22 @@
/*
* Dashboard layout for the 800x480 panel.
*
* Five horizontal bands, sized to fill exactly one screen with no scrolling:
*
* header 42px date, clock, current condition
* body 182px outdoor temperature card + a 3x2 grid of readings
* lights 116px one tile per HA_LIGHTS entry
* forecast 86px five day columns
* status 14px WiFi / poll failures
*
* Nothing is hidden behind navigation: this is a wall panel that has to be
* readable from across the room at a glance.
*
* Compiled into the desktop simulator as well as the firmware, so no driver,
* socket or ESP-IDF calls belong here. See sim/README.md.
*/
#include <math.h>
#include <stdio.h>
#include <string.h>
@@ -12,24 +31,101 @@ static const char *TAG = "ui_manager";
#define UI_LOCK_TIMEOUT_MS 1000
/*
* Bring-up diagnostic: a live readout of the last touch coordinate. Off by
* default -- define UI_DEBUG_TOUCH to put it back on screen when working on
* the input path.
* Band heights.
*
* The screen is 480px with 8px of padding top and bottom, so the content area
* is 464px. Five bands separated by four gaps must fit that exactly:
*
* 42 + 182 + 116 + 86 + 14 = 440 bands
* + 4 * 6 = 24 gaps
* 464 = 480 - 2*SCREEN_PAD
*
* Overrunning does not clip visibly -- flex silently pushes the last band off
* the bottom, which is how the status line vanished the first time. If you
* change one of these, change another to compensate and keep the sum at 464.
*/
/* #define UI_DEBUG_TOUCH */
#define SCREEN_PAD 8
#define BAND_HEADER_H 42
#define BAND_BODY_H 182
#define BAND_LIGHTS_H 116
#define BAND_FORECAST_H 86
#define BAND_STATUS_H 14
#define BAND_GAP 6
static lv_obj_t *temp_label = NULL;
static lv_obj_t *cond_label = NULL;
static lv_obj_t *status_label = NULL;
#ifdef UI_DEBUG_TOUCH
static lv_obj_t *touch_label = NULL;
#endif
/* Palette. Kept together so a retheme is one edit, not thirty. */
#define COL_BG 0x131C28
#define COL_PANEL 0x1E2A3A
#define COL_PANEL_HI 0x27384D
#define COL_ACCENT 0x3498DB
#define COL_ACCENT_WARM 0xE8A33D
#define COL_TEXT 0xFFFFFF
#define COL_TEXT_DIM 0x8A99AB
#define COL_TEXT_FAINT 0x5A6672
#define COL_ON 0xF5C542
#define COL_STALE 0x5A6672
#define COL_TRACK 0x0C131C /* slider groove; must read as a groove
against COL_PANEL, not blend into it */
/*
* lv_label_set_text() invalidates the label unconditionally, even when the
* new text is identical -- which would repaint the temperature every poll
* whether or not it moved. Comparing first means an unchanged value costs
* nothing: no invalidation, so no redraw and no flush.
* A reading with no value renders as a dash rather than 0 or a blank. Blank
* reads as "this panel is broken"; 0 reads as a measurement. Neither is true
* when a sensor is unavailable -- which for the wind sensors is the normal
* state, see ha_config.h.
*
* ASCII hyphens, not an em dash. LVGL's Montserrat builds cover Latin-1 plus
* a few symbols; U+2014 is not among them and renders as a tofu box. The
* degree sign is fine, U+00B7 and U+2014 are not.
*/
#define NO_VALUE "--"
static lv_obj_t *clock_label;
static lv_obj_t *date_label;
static lv_obj_t *cond_label;
static lv_obj_t *temp_label;
static lv_obj_t *temp_unit_label;
static lv_obj_t *outdoor_detail_label;
/* 3x2 reading grid: value and caption per cell. */
enum {
CELL_INDOOR = 0,
CELL_WIND,
CELL_RAIN,
CELL_SUNRISE,
CELL_SUNSET,
CELL_PRESSURE,
CELL_COUNT,
};
static lv_obj_t *cell_value[CELL_COUNT];
static lv_obj_t *cell_sub[CELL_COUNT];
static lv_obj_t *light_tile[HA_LIGHT_COUNT];
static lv_obj_t *light_name[HA_LIGHT_COUNT];
static lv_obj_t *light_state[HA_LIGHT_COUNT];
static lv_obj_t *light_slider[HA_LIGHT_COUNT];
static lv_obj_t *fc_day[HA_FORECAST_DAYS];
static lv_obj_t *fc_cond[HA_FORECAST_DAYS];
static lv_obj_t *fc_temp[HA_FORECAST_DAYS];
static lv_obj_t *status_label;
static ui_light_cmd_cb_t light_cb;
/*
* Last state pushed to the widgets, so ui_update() can skip a light tile whose
* state and brightness are unchanged. Without this, every poll would restyle
* six tiles and move six sliders.
*/
static ha_light_status_t last_light[HA_LIGHT_COUNT];
static bool last_light_valid;
/* ------------------------------------------------------------- primitives */
/*
* lv_label_set_text() invalidates unconditionally, even when the new text is
* identical -- which would repaint the whole dashboard every poll whether or
* not anything moved. Comparing first means an unchanged value costs nothing.
*/
static void label_set_if_changed(lv_obj_t *label, const char *text)
{
@@ -45,52 +141,489 @@ static void label_set_if_changed(lv_obj_t *label, const char *text)
lv_label_set_text(label, text);
}
/*
* NOTE, even with the readout compiled out: LVGL does not bubble events to
* parents by default. An event handler on the screen object never fires for
* taps landing on a child widget -- the header or the temperature card
* consume them -- which presents as whole regions of the panel being dead to
* touch while the hardware reports them perfectly well. Anything that needs
* to observe touches screen-wide must poll the input device, as below, rather
* than hang an event handler on the screen.
*/
#ifdef UI_DEBUG_TOUCH
static void touch_readout_timer_cb(lv_timer_t *timer)
/* Formats a reading, or NO_VALUE when the sensor had nothing to say. */
static void format_value(const ha_value_t *v, const char *fmt, char *buf, size_t len)
{
lv_indev_t *indev = lv_indev_get_next(NULL);
if (indev == NULL || touch_label == NULL) {
return;
}
lv_point_t p;
lv_indev_get_point(indev, &p);
char buf[48];
if (lv_indev_get_state(indev) == LV_INDEV_STATE_PRESSED) {
snprintf(buf, sizeof(buf), "touch %d, %d", (int)p.x, (int)p.y);
if (v->valid) {
snprintf(buf, len, fmt, v->value);
} else {
snprintf(buf, sizeof(buf), "touch %d, %d (released)", (int)p.x, (int)p.y);
snprintf(buf, len, "%s", NO_VALUE);
}
label_set_if_changed(touch_label, buf);
}
#endif
void ui_update_weather(int16_t temp_f, const char *condition)
static lv_obj_t *panel_create(lv_obj_t *parent, int32_t w, int32_t h, uint32_t colour)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped weather update: LVGL lock timed out");
lv_obj_t *p = lv_obj_create(parent);
lv_obj_set_size(p, w, h);
lv_obj_set_style_bg_color(p, lv_color_hex(colour), LV_PART_MAIN);
lv_obj_set_style_border_width(p, 0, LV_PART_MAIN);
lv_obj_set_style_radius(p, 10, LV_PART_MAIN);
lv_obj_set_style_pad_all(p, 8, LV_PART_MAIN);
lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE);
return p;
}
static lv_obj_t *label_create(lv_obj_t *parent, const char *text,
const lv_font_t *font, uint32_t colour)
{
lv_obj_t *l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(colour), LV_PART_MAIN);
return l;
}
/* ------------------------------------------------------------ light tiles */
/*
* Both handlers run on the LVGL task with the lock held. They only forward to
* light_cb, which must not block -- see ui_manager.h.
*/
static void light_tile_clicked(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
char buf[32];
snprintf(buf, sizeof(buf), "%d°F", temp_f);
/* Unavailable entities are not toggleable; the tile is styled to show it,
* but a stray tap should still do nothing rather than send a doomed
* command. */
if (last_light_valid && last_light[idx].state == HA_LIGHT_UNAVAILABLE) {
return;
}
const bool turn_on = !(last_light_valid && last_light[idx].state == HA_LIGHT_ON);
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
* the current brightness here would fight a user who dimmed the light
* elsewhere. */
light_cb(idx, turn_on, -1);
}
static void light_slider_changed(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
lv_obj_t *slider = lv_event_get_target_obj(e);
const int32_t pct = lv_slider_get_value(slider);
/* Dragging to zero means off, not "on at zero brightness" -- HA accepts
* brightness 0 but leaves the light on, which looks like a bug. */
if (pct <= 0) {
light_cb(idx, false, -1);
return;
}
light_cb(idx, true, (int16_t)((pct * 255) / 100));
}
static void light_tile_build(lv_obj_t *parent, int idx, int32_t w, int32_t h)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
lv_obj_t *tile = panel_create(parent, w, h, COL_PANEL);
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tile, light_tile_clicked, LV_EVENT_CLICKED,
(void *)(intptr_t)idx);
lv_obj_set_style_pad_all(tile, 10, LV_PART_MAIN);
light_tile[idx] = tile;
/*
* Explicit alignment rather than flex. A flex column packs the three
* children against the top and leaves the rest of a 116px tile empty; the
* slider wants to sit at the bottom where a thumb reaches it.
*/
light_name[idx] = label_create(tile, cfg->label, &lv_font_montserrat_16, COL_TEXT);
lv_label_set_long_mode(light_name[idx], LV_LABEL_LONG_DOT);
lv_obj_set_width(light_name[idx], LV_PCT(100));
/*
* An explicit one-line height is what actually forces truncation. With a
* content-sized height LV_LABEL_LONG_DOT has room to grow, so it wraps to
* a second line first and only then adds dots -- and that second line
* lands on top of the state label below.
*/
lv_obj_set_height(light_name[idx], lv_font_get_line_height(&lv_font_montserrat_16));
lv_obj_align(light_name[idx], LV_ALIGN_TOP_LEFT, 0, 0);
light_state[idx] = label_create(tile, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_align(light_state[idx], LV_ALIGN_TOP_LEFT, 0, 24);
if (cfg->dimmable) {
lv_obj_t *s = lv_slider_create(tile);
lv_obj_set_width(s, LV_PCT(100));
lv_obj_set_height(s, 12);
lv_obj_align(s, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_slider_set_range(s, 0, 100);
/*
* The track needs real contrast against the tile. COL_PANEL_HI is
* only a shade off the tile background, which made an off light's
* slider look like a lone floating knob.
*/
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TRACK), LV_PART_MAIN);
lv_obj_set_style_bg_color(s, lv_color_hex(COL_ON), LV_PART_INDICATOR);
/* Dim knob, not white. A white knob on an off tile is the highest
* contrast thing in the row, so six dark tiles read as six bright
* dots -- the eye goes straight to the controls that are doing
* nothing. */
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TEXT_DIM), LV_PART_KNOB);
lv_obj_set_style_pad_all(s, 2, LV_PART_KNOB);
/* RELEASED, not VALUE_CHANGED: the latter fires continuously while a
* finger drags, which would queue a command per pixel. */
lv_obj_add_event_cb(s, light_slider_changed, LV_EVENT_RELEASED,
(void *)(intptr_t)idx);
light_slider[idx] = s;
} else {
/* The patio is a smart plug in the switch domain -- no brightness at
* all, so its tile gets a label where the slider would be rather than
* a dead gap that looks like a missing control. */
lv_obj_t *note = label_create(tile, "switch", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_align(note, LV_ALIGN_BOTTOM_LEFT, 0, 0);
light_slider[idx] = NULL;
}
}
static void light_tile_update(int idx, const ha_light_status_t *st)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
const bool on = (st->state == HA_LIGHT_ON);
const bool dead = (st->state == HA_LIGHT_UNAVAILABLE);
lv_obj_set_style_bg_color(light_tile[idx],
lv_color_hex(on ? COL_PANEL_HI : COL_PANEL),
LV_PART_MAIN);
lv_obj_set_style_border_width(light_tile[idx], on ? 2 : 0, LV_PART_MAIN);
lv_obj_set_style_border_color(light_tile[idx], lv_color_hex(COL_ON), LV_PART_MAIN);
lv_obj_set_style_text_color(light_name[idx],
lv_color_hex(dead ? COL_TEXT_FAINT : COL_TEXT),
LV_PART_MAIN);
char buf[24];
if (dead) {
snprintf(buf, sizeof(buf), "unavailable");
} else if (!on) {
snprintf(buf, sizeof(buf), "Off");
} else if (cfg->dimmable && st->brightness >= 0) {
/* HA's 0-255 shown as a percentage; +127 rounds to nearest so 255
* reads as 100%% and not 99%%. */
snprintf(buf, sizeof(buf), "On %d%%", (st->brightness * 100 + 127) / 255);
} else {
snprintf(buf, sizeof(buf), "On");
}
label_set_if_changed(light_state[idx], buf);
lv_obj_set_style_text_color(light_state[idx],
lv_color_hex(on ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
if (light_slider[idx] != NULL) {
const int32_t pct = (on && st->brightness >= 0)
? (st->brightness * 100 + 127) / 255
: 0;
lv_slider_set_value(light_slider[idx], pct, LV_ANIM_OFF);
/* Bright knob only while the light is on, so the lit tiles are what
* draw the eye along the row. */
lv_obj_set_style_bg_color(light_slider[idx],
lv_color_hex(on ? COL_TEXT : COL_TEXT_DIM),
LV_PART_KNOB);
if (dead) {
lv_obj_add_state(light_slider[idx], LV_STATE_DISABLED);
} else {
lv_obj_remove_state(light_slider[idx], LV_STATE_DISABLED);
}
}
}
/* ------------------------------------------------------------------ build */
static void build_header(lv_obj_t *scr)
{
lv_obj_t *bar = panel_create(scr, LV_PCT(100), BAND_HEADER_H, COL_PANEL);
lv_obj_set_style_radius(bar, 0, LV_PART_MAIN);
lv_obj_set_style_pad_hor(bar, 16, LV_PART_MAIN);
date_label = label_create(bar, "--", &lv_font_montserrat_20, COL_TEXT_DIM);
lv_obj_align(date_label, LV_ALIGN_LEFT_MID, 0, 0);
clock_label = label_create(bar, "--:--", &lv_font_montserrat_24, COL_TEXT);
lv_obj_align(clock_label, LV_ALIGN_CENTER, 0, 0);
cond_label = label_create(bar, "--", &lv_font_montserrat_20, COL_ACCENT);
lv_obj_align(cond_label, LV_ALIGN_RIGHT_MID, 0, 0);
}
static void build_body(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_BODY_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
/* --- outdoor card --- */
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL);
lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN);
lv_obj_t *heading = label_create(card, "OUTDOOR", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
/* Centred vertically rather than pinned under the heading: the card is
* 182px and a 48px number at the top leaves an obvious hole in the middle
* of the most important element on the screen. */
temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT);
lv_obj_align(temp_label, LV_ALIGN_LEFT_MID, 0, 0);
/* Degree sign as its own label so it can sit small and high beside a
* 48px number instead of scaling with it. */
temp_unit_label = label_create(card, "°F", &lv_font_montserrat_20, COL_TEXT_DIM);
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
outdoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM);
lv_obj_align(outdoor_detail_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
/* --- 3x2 reading grid --- */
lv_obj_t *grid = lv_obj_create(row);
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
lv_obj_set_flex_grow(grid, 1);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(grid, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(grid, 0, LV_PART_MAIN);
lv_obj_set_style_pad_gap(grid, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
static const char *const captions[CELL_COUNT] = {
"INDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE",
};
/* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the
* wrap puts a third row below the fold. */
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
for (int i = 0; i < CELL_COUNT; i++) {
lv_obj_t *cell = panel_create(grid, 156, cell_h, COL_PANEL);
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
lv_obj_align(cap, LV_ALIGN_TOP_LEFT, 0, 0);
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_24, COL_TEXT);
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
cell_sub[i] = label_create(cell, "", &lv_font_montserrat_12, COL_TEXT_DIM);
lv_obj_align(cell_sub[i], LV_ALIGN_BOTTOM_LEFT, 0, 0);
}
}
static void build_lights(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_LIGHTS_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
const int32_t tile_w = (800 - 2 * SCREEN_PAD - (HA_LIGHT_COUNT - 1) * BAND_GAP)
/ HA_LIGHT_COUNT;
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
light_tile_build(row, i, tile_w, LV_PCT(100));
}
}
static void build_forecast(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_FORECAST_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
const int32_t col_w = (800 - 2 * SCREEN_PAD - (HA_FORECAST_DAYS - 1) * BAND_GAP)
/ HA_FORECAST_DAYS;
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
lv_obj_t *col = panel_create(row, col_w, LV_PCT(100), COL_PANEL);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(col, 2, LV_PART_MAIN);
fc_day[i] = label_create(col, "--", &lv_font_montserrat_16, COL_TEXT);
fc_cond[i] = label_create(col, "--", &lv_font_montserrat_12, COL_TEXT_DIM);
fc_temp[i] = label_create(col, NO_VALUE, &lv_font_montserrat_16, COL_ACCENT);
}
}
/* ----------------------------------------------------------------- update */
static void update_body(const ha_data_t *d)
{
char buf[48];
char sub[48];
/* Outdoor card. Rounded with lroundf, not truncation: below 0 degF a cast
* rounds toward zero and turns -10.6 into -10. */
if (d->outdoor_temp_f.valid) {
snprintf(buf, sizeof(buf), "%d", (int)lroundf(d->outdoor_temp_f.value));
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(temp_label, buf);
label_set_if_changed(cond_label, condition);
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
snprintf(buf, sizeof(buf), "%.0f%% humidity dew %.0f°",
d->outdoor_humidity.value, d->dew_point_f.value);
} else if (d->outdoor_humidity.valid) {
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->outdoor_humidity.value);
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(outdoor_detail_label, buf);
/* Indoor */
format_value(&d->indoor_temp_f, "%.1f°", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_INDOOR], buf);
if (d->indoor_humidity.valid) {
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->indoor_humidity.value);
} else {
sub[0] = '\0';
}
label_set_if_changed(cell_sub[CELL_INDOOR], sub);
/*
* Wind. The station anemometer has been offline since 2026-07-27, so this
* cell is expected to read "--" until the hardware is fixed. It is
* deliberately NOT backfilled from the met.no forecast: showing regional
* modelled wind under a heading that means "my yard" is worse than
* showing nothing.
*/
format_value(&d->wind_mph, "%.0f", buf, sizeof(buf));
if (d->wind_mph.valid) {
const size_t n = strlen(buf);
snprintf(buf + n, sizeof(buf) - n, " mph");
}
label_set_if_changed(cell_value[CELL_WIND], buf);
if (d->wind_dir_deg.valid && d->gust_mph.valid) {
snprintf(sub, sizeof(sub), "%s gust %.0f",
ha_wind_cardinal(d->wind_dir_deg.value), d->gust_mph.value);
} else if (d->wind_dir_deg.valid) {
snprintf(sub, sizeof(sub), "%s", ha_wind_cardinal(d->wind_dir_deg.value));
} else {
snprintf(sub, sizeof(sub), "sensor offline");
}
label_set_if_changed(cell_sub[CELL_WIND], sub);
/* Rain */
format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_RAIN], buf);
if (d->rain_rate_inhr.valid && d->rain_rate_inhr.value > 0.0f) {
snprintf(sub, sizeof(sub), "%.2f in/h now", d->rain_rate_inhr.value);
} else {
snprintf(sub, sizeof(sub), "not raining");
}
label_set_if_changed(cell_sub[CELL_RAIN], sub);
/* Sun. These strings are already local time from HA -- the board has no
* clock of its own. */
label_set_if_changed(cell_value[CELL_SUNRISE],
d->sunrise[0] ? d->sunrise : NO_VALUE);
label_set_if_changed(cell_sub[CELL_SUNRISE], d->sun_up ? "up now" : "before dawn");
label_set_if_changed(cell_value[CELL_SUNSET],
d->sunset[0] ? d->sunset : NO_VALUE);
label_set_if_changed(cell_sub[CELL_SUNSET], d->sun_up ? "still up" : "down");
/* Pressure */
format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_PRESSURE], buf);
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
}
static void update_forecast(const ha_data_t *d)
{
char buf[32];
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
if (i >= d->forecast_count) {
label_set_if_changed(fc_day[i], "--");
label_set_if_changed(fc_cond[i], "");
label_set_if_changed(fc_temp[i], NO_VALUE);
continue;
}
const ha_forecast_day_t *f = &d->forecast[i];
label_set_if_changed(fc_day[i], f->day);
label_set_if_changed(fc_cond[i], ha_condition_short(f->condition));
if (f->high_f.valid && f->low_f.valid) {
snprintf(buf, sizeof(buf), "%d / %d",
(int)lroundf(f->high_f.value), (int)lroundf(f->low_f.value));
} else if (f->high_f.valid) {
snprintf(buf, sizeof(buf), "%d", (int)lroundf(f->high_f.value));
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(fc_temp[i], buf);
}
}
void ui_update(const ha_data_t *data)
{
if (data == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped update: LVGL lock timed out");
return;
}
label_set_if_changed(clock_label, data->clock[0] ? data->clock : "--:--");
label_set_if_changed(date_label, data->date[0] ? data->date : "--");
label_set_if_changed(cond_label, ha_condition_label(data->condition));
update_body(data);
update_forecast(data);
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
/* Restyling a tile is many style writes; skip the ones that did not
* move. */
if (last_light_valid &&
last_light[i].state == data->lights[i].state &&
last_light[i].brightness == data->lights[i].brightness) {
continue;
}
light_tile_update(i, &data->lights[i]);
last_light[i] = data->lights[i];
}
last_light_valid = true;
lvgl_port_unlock();
}
void ui_set_weather_stale(bool stale)
void ui_set_stale(bool stale)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
@@ -101,7 +634,7 @@ void ui_set_weather_stale(bool stale)
/* Unlike lv_label_set_text(), setting an identical style value does
* not invalidate, so this is safe to call on every poll. */
lv_obj_set_style_text_color(temp_label,
stale ? lv_color_hex(0x5A6672) : lv_color_white(),
lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN);
}
@@ -124,6 +657,11 @@ void ui_set_status(const char *status)
lvgl_port_unlock();
}
void ui_set_light_cb(ui_light_cmd_cb_t cb)
{
light_cb = cb;
}
void ui_init(void)
{
/* ui_init() runs from app_main, not the LVGL task, so it needs the lock
@@ -134,59 +672,27 @@ void ui_init(void)
}
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A2332), LV_PART_MAIN);
lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), LV_PART_MAIN);
lv_obj_set_style_pad_all(scr, SCREEN_PAD, LV_PART_MAIN);
lv_obj_set_style_pad_row(scr, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(scr, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *header = lv_obj_create(scr);
lv_obj_set_size(header, LV_PCT(100), 64);
lv_obj_set_style_radius(header, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(header, lv_color_hex(0x0D1620), LV_PART_MAIN);
lv_obj_set_style_border_width(header, 0, LV_PART_MAIN);
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
build_header(scr);
build_body(scr);
build_lights(scr);
build_forecast(scr);
lv_obj_t *title = lv_label_create(header);
lv_label_set_text(title, "Thermostat");
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_text_font(title, &lv_font_montserrat_28, LV_PART_MAIN);
lv_obj_center(title);
lv_obj_t *card = lv_obj_create(scr);
lv_obj_set_size(card, LV_PCT(80), LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(card, lv_color_hex(0x2C3E50), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 2, LV_PART_MAIN);
lv_obj_set_style_border_color(card, lv_color_hex(0x3498DB), LV_PART_MAIN);
lv_obj_set_style_radius(card, 12, LV_PART_MAIN);
lv_obj_set_style_pad_all(card, 24, LV_PART_MAIN);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
temp_label = lv_label_create(card);
lv_label_set_text(temp_label, "--°F");
lv_obj_set_style_text_color(temp_label, lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, LV_PART_MAIN);
cond_label = lv_label_create(card);
lv_label_set_text(cond_label, "Waiting for data...");
lv_obj_set_style_text_color(cond_label, lv_color_hex(0xBDC3C7), LV_PART_MAIN);
status_label = lv_label_create(scr);
lv_label_set_text(status_label, "Starting...");
lv_obj_set_style_text_color(status_label, lv_color_hex(0x7F8C8D), LV_PART_MAIN);
#ifdef UI_DEBUG_TOUCH
touch_label = lv_label_create(scr);
lv_label_set_text(touch_label, "touch --, --");
lv_obj_set_style_text_color(touch_label, lv_color_hex(0x3498DB), LV_PART_MAIN);
lv_timer_create(touch_readout_timer_cb, 100, NULL);
#endif
/* Its own band, accounted for in the BAND_* budget. Usually says
* something dull about WiFi; when a poll starts failing it is the only
* place that says so. */
status_label = label_create(scr, "Starting...", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_height(status_label, BAND_STATUS_H);
lv_obj_set_width(status_label, LV_PCT(100));
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lvgl_port_unlock();
ESP_LOGI(TAG, "UI initialized");
ESP_LOGI(TAG, "UI initialized (%d lights, %d forecast days)",
HA_LIGHT_COUNT, HA_FORECAST_DAYS);
}
+31 -3
View File
@@ -3,19 +3,47 @@
#include <stdbool.h>
#include <stdint.h>
#include "ha_data.h"
#include "lvgl.h"
/*
* The screen layout.
*
* This file is compiled into the desktop simulator as well as the firmware
* (see sim/README.md), so it must never touch a driver, a socket or ESP-IDF
* directly. Data arrives as ha_data_t; commands leave through the callback
* below. Everything in between is drawing.
*/
void ui_init(void);
/* Safe to call from any task -- these take the LVGL lock internally. */
void ui_update_weather(int16_t temp_f, const char *condition);
/* Redraw every panel from `data`. Cheap to call when nothing changed: every
* label goes through a comparison first, so an unchanged value costs no
* invalidation and therefore no flush. */
void ui_update(const ha_data_t *data);
void ui_set_status(const char *status);
/*
* Mark the displayed reading as no longer trustworthy. Dims the temperature
* Mark the displayed readings as no longer trustworthy. Dims the temperature
* so a value that stopped updating is visibly distinct from a live one,
* rather than sitting there at full styling looking current.
*/
void ui_set_weather_stale(bool stale);
void ui_set_stale(bool stale);
/*
* Called when the user operates a light tile.
*
* `index` indexes HA_LIGHTS. `brightness` is 0-255, or -1 for a plain on/off
* with no brightness change (a tile tap, or any switch-domain entity).
*
* Runs on the LVGL task with the LVGL lock held, so the implementation must
* not block: hand the command to another task rather than doing HTTP here.
*/
typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness);
void ui_set_light_cb(ui_light_cmd_cb_t cb);
#endif
+27 -1
View File
@@ -30,9 +30,35 @@ main/
│ ├── ui_manager.h
│ └── ui_manager.c
└── api/
├── ha_config.h (entity IDs -- tracked, not secret)
├── ha_template.h (Jinja template, built from the HA_LIGHT_LIST X-macro)
├── ha_data.h
├── ha_data.c (parsing + presentation -- SHARED with sim/)
├── ha_client.h
├── ha_client.c (esp_http_client transport -- device only)
├── ambient_weather.h
└── ambient_weather.c
└── ambient_weather.c (cloud fallback, used only when HA is unreachable)
sim/ (desktop simulator -- NOT part of the IDF build)
├── CMakeLists.txt (own cmake project; idf.py never looks in here)
├── lv_conf.h (host LVGL config; font list must match sdkconfig.defaults)
├── sim_main.c (SDL window / headless PNG capture, mock data)
├── lvgl_port_sim.c (host implementation of main/ui/lvgl_port.h)
├── README.md
├── snapshots/ (captured HA responses, used as render fixtures)
└── compat/ (ESP-IDF header stand-ins, so main/ui/*.c compiles as-is)
├── esp_err.h
├── esp_log.h
├── esp_check.h
└── esp_compat.c
tools/
├── ha_discover.py (list Home Assistant entity IDs; reads $HA_TOKEN)
└── ha_snapshot.py (capture HA responses into sim/snapshots/)
Note: main/ subdirectories are plain source folders, not ESP-IDF components.
IDF only auto-discovers components under a top-level components/ directory,
so all sources are registered by main/CMakeLists.txt.
The simulator compiles main/ui/ui_manager.c unmodified against desktop LVGL,
which is why that file must stay free of hardware access. See sim/README.md.
+10
View File
@@ -76,8 +76,18 @@ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
CONFIG_LV_COLOR_DEPTH_16=y
CONFIG_LV_USE_LOG=y
CONFIG_LV_LOG_LEVEL_WARN=y
# Must stay identical to the LV_FONT_MONTSERRAT_* set in sim/lv_conf.h. A font
# enabled in one build but not the other makes the simulator lie about layout:
# text silently falls back to a different size and every metric shifts.
CONFIG_LV_FONT_MONTSERRAT_12=y
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_16=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_FONT_MONTSERRAT_32=y
CONFIG_LV_FONT_MONTSERRAT_36=y
CONFIG_LV_FONT_MONTSERRAT_40=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# --- Main task needs headroom for driver init ---
+90
View File
@@ -0,0 +1,90 @@
# Desktop simulator for the thermostat UI. Not part of the ESP-IDF build --
# `idf.py build` never looks in here, and this never builds firmware sources
# that touch hardware.
#
# cmake -S sim -B sim/build -G Ninja && cmake --build sim/build
#
# See sim/README.md.
cmake_minimum_required(VERSION 3.16)
project(thermostat_sim C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(FW_ROOT ${CMAKE_CURRENT_LIST_DIR}/..)
set(LVGL_DIR ${FW_ROOT}/managed_components/lvgl__lvgl)
# LVGL is pulled in by the IDF component manager, so it lives in a gitignored
# directory that only exists after an IDF build. Using it anyway -- rather than
# fetching a second copy -- guarantees the simulator and the firmware render
# with the identical LVGL version. A simulator on a different version is a
# simulator that lies.
if(NOT EXISTS ${LVGL_DIR}/lvgl.h)
message(FATAL_ERROR
"LVGL not found at ${LVGL_DIR}\n"
"It is fetched by the ESP-IDF component manager. Run once:\n"
" . ~/esp/esp-idf/export.sh && idf.py reconfigure")
endif()
# Nothing here uses LVGL's bundled demos or examples, and they add real build
# time. ThorVG is a C++ vector engine we do not use either.
set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE)
set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE)
# LVGL's desktop CMake picks up lv_conf.h from CMAKE_SOURCE_DIR, which is this
# directory.
add_subdirectory(${LVGL_DIR} ${CMAKE_BINARY_DIR}/lvgl)
find_package(SDL2 REQUIRED)
# cJSON comes from the ESP-IDF tree rather than the host's libcjson, so the
# simulator parses Home Assistant replies with the exact same parser the
# firmware uses. IDF_PATH is only needed for this one directory -- the rest of
# the simulator builds with no ESP-IDF environment at all.
if(DEFINED ENV{IDF_PATH})
set(IDF_ROOT $ENV{IDF_PATH})
else()
set(IDF_ROOT $ENV{HOME}/esp/esp-idf)
endif()
set(CJSON_DIR ${IDF_ROOT}/components/json/cJSON)
if(NOT EXISTS ${CJSON_DIR}/cJSON.c)
message(FATAL_ERROR
"cJSON not found at ${CJSON_DIR}\n"
"Set IDF_PATH, or install ESP-IDF to ~/esp/esp-idf.")
endif()
add_executable(thermostat_sim
sim_main.c
lvgl_port_sim.c
compat/esp_compat.c
# The point of the exercise: real firmware sources, compiled unmodified.
# Anything added here must stay free of hardware access -- drivers and
# HTTP transport stay on the device side.
${FW_ROOT}/main/ui/ui_manager.c
${FW_ROOT}/main/api/ha_data.c
${CJSON_DIR}/cJSON.c
)
target_include_directories(thermostat_sim PRIVATE
${CMAKE_CURRENT_LIST_DIR}
${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins
${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h
${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h
${CJSON_DIR}
${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode
${SDL2_INCLUDE_DIRS}
)
# SDL2_LIBRARIES can carry trailing whitespace from sdl2-config, which makes
# the link line fail in confusing ways.
string(STRIP "${SDL2_LIBRARIES}" SDL2_LIBRARIES)
target_link_libraries(thermostat_sim PRIVATE lvgl ${SDL2_LIBRARIES} m pthread)
target_compile_options(thermostat_sim PRIVATE -Wall -Wextra -Wno-unused-parameter)
+109
View File
@@ -0,0 +1,109 @@
# Desktop simulator
Runs the firmware's UI on a Linux desktop so layout work does not need a
flash-and-look cycle. `main/ui/ui_manager.c` is compiled **unmodified** — the
same source the device runs.
## Build
```bash
cmake -S sim -B sim/build -G Ninja
cmake --build sim/build
```
LVGL comes from `managed_components/lvgl__lvgl`, which the ESP-IDF component
manager populates. If that directory is missing, CMake says so; run
`. ~/esp/esp-idf/export.sh && idf.py reconfigure` once to fetch it.
No ESP-IDF environment is needed to *build* the simulator — only a host
compiler, CMake, Ninja and `libsdl2-dev`.
## Run
```bash
./sim/build/thermostat_sim # SDL window, mouse acts as touch
./sim/build/thermostat_sim --png shot.png # render one frame, write a PNG, exit
./sim/build/thermostat_sim --png shot.png --settle-ms 1500
```
The `--png` mode is what makes UI review possible without a human at a screen:
it renders headless, so it works over SSH and in CI, and the resulting image
can be inspected directly.
Clicking a light tile in the window calls the same `ui_set_light_cb` path the
firmware uses; the simulator applies the change locally and logs the command,
so tiles respond with no Home Assistant present.
## Snapshots
The interesting screens — rain falling, a light dimmed, a sensor dropping out
— do not happen on demand, and live data changes underneath a before/after
comparison. So capture real responses and render those:
```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
```
Snapshots are plain JSON and are meant to be hand-edited into states that have
not occurred yet — `sim/snapshots/evening.json` was made that way, to check
the on/dimmed/unavailable tile styling while every real light was off.
`ha_snapshot.py` does **not** carry its own copy of the Jinja template. It
reads it from `thermostat_sim --print-template`, which the preprocessor builds
from `main/api/ha_template.h`, so a snapshot cannot be captured with a
template the firmware does not use.
## How it stays honest
A simulator that renders differently from the panel is worse than none, so
these are deliberately pinned to the device:
| Property | Value | Enforced by |
|---|---|---|
| Resolution | 800×480 | `PANEL_H_RES/V_RES` in `sim_main.c` |
| Colour depth | RGB565 | `LV_COLOR_DEPTH 16` in `lv_conf.h` |
| Render mode | `DIRECT` | `lv_conf.h` / `headless_display_create()` |
| LVGL version | whatever IDF resolved | builds the same `managed_components` tree |
| Fonts | Montserrat 12–48 | `lv_conf.h` **and** `sdkconfig.defaults` |
**The font lists must be edited together.** Enabling a size in one build and
not the other makes the simulator lie: text silently falls back to a different
size and every metric shifts.
## What it does not simulate
- **PSRAM bandwidth, the bounce-buffer refill ISR, and RGB scan timing.** The
left-edge artefacts in the root README are invisible here by construction.
This tool answers "does the layout look right", never "does the panel keep
up". Timing-sensitive changes still need hardware.
- **Touch hardware.** The mouse is a stand-in for the GT911: one contact
point, pixel-accurate, no palm rejection, and none of the I2C contention the
real panel has with the backlight controller.
- **Anything in `main/drivers/`.** Those touch real peripherals and are never
compiled here.
## Layout
| Path | Purpose |
|---|---|
| `sim_main.c` | Entry point: display setup, PNG capture, mock data |
| `lvgl_port_sim.c` | Host implementation of the firmware's `lvgl_port.h` |
| `compat/` | Stand-ins for the ESP-IDF headers shared sources include |
| `lv_conf.h` | Host LVGL config, mirroring the device's Kconfig settings |
`compat/` exists so firmware sources compile as-is instead of growing
`#ifdef`s. If a newly shared source fails to compile, extend `compat/` — do
not fork the firmware source.
## Gotchas
- **Montserrat has no `·` (U+00B7).** It renders as a tofu box.
`weather_get_string()` uses one, but its output only ever reaches a log
line. Anything routed to an on-screen label must avoid it.
- **`lodepng_encode32_file()` does not work here.** LVGL patches its bundled
lodepng to do file I/O through `lv_fs`, which needs a registered driver and
drive-letter paths. `sim_main.c` encodes to memory and uses `fwrite`.
+46
View File
@@ -0,0 +1,46 @@
/*
* Host stand-in for ESP-IDF's esp_check.h -- see compat/esp_err.h.
*/
#ifndef ESP_CHECK_H
#define ESP_CHECK_H
#include "esp_err.h"
#include "esp_log.h"
#define ESP_RETURN_ON_ERROR(x, tag, fmt, ...) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
return err_rc_; \
} \
} while (0)
#define ESP_RETURN_ON_FALSE(a, err_code, tag, fmt, ...) \
do { \
if (!(a)) { \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
return (err_code); \
} \
} while (0)
#define ESP_GOTO_ON_ERROR(x, goto_tag, tag, fmt, ...) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
ret = err_rc_; \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
goto goto_tag; \
} \
} while (0)
#define ESP_GOTO_ON_FALSE(a, err_code, goto_tag, tag, fmt, ...) \
do { \
if (!(a)) { \
ret = (err_code); \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
goto goto_tag; \
} \
} while (0)
#endif
+28
View File
@@ -0,0 +1,28 @@
#include <stdio.h>
#include <stdlib.h>
#include "esp_err.h"
const char *esp_err_to_name(esp_err_t code)
{
switch (code) {
case ESP_OK: return "ESP_OK";
case ESP_FAIL: return "ESP_FAIL";
case ESP_ERR_NO_MEM: return "ESP_ERR_NO_MEM";
case ESP_ERR_INVALID_ARG: return "ESP_ERR_INVALID_ARG";
case ESP_ERR_INVALID_STATE: return "ESP_ERR_INVALID_STATE";
case ESP_ERR_INVALID_SIZE: return "ESP_ERR_INVALID_SIZE";
case ESP_ERR_NOT_FOUND: return "ESP_ERR_NOT_FOUND";
case ESP_ERR_NOT_SUPPORTED: return "ESP_ERR_NOT_SUPPORTED";
case ESP_ERR_TIMEOUT: return "ESP_ERR_TIMEOUT";
case ESP_ERR_INVALID_RESPONSE: return "ESP_ERR_INVALID_RESPONSE";
default: return "ESP_ERR_UNKNOWN";
}
}
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr)
{
fprintf(stderr, "ESP_ERROR_CHECK failed: %s (0x%x) at %s:%d\n expression: %s\n",
esp_err_to_name(rc), rc, file, line, expr);
abort();
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Host stand-in for ESP-IDF's esp_err.h.
*
* The simulator compiles the real firmware UI sources unmodified. Those
* sources include ESP-IDF headers, so the simulator supplies its own versions
* on the include path rather than sprinkling #ifdefs through main/. Only the
* surface the shared sources actually use is defined here -- if a new firmware
* source is added to the simulator build and fails to compile, extend this
* file, do not fork the firmware source.
*/
#ifndef ESP_ERR_H
#define ESP_ERR_H
#include <stdbool.h>
#include <stdint.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 0x101
#define ESP_ERR_INVALID_ARG 0x102
#define ESP_ERR_INVALID_STATE 0x103
#define ESP_ERR_INVALID_SIZE 0x104
#define ESP_ERR_NOT_FOUND 0x105
#define ESP_ERR_NOT_SUPPORTED 0x106
#define ESP_ERR_TIMEOUT 0x107
#define ESP_ERR_INVALID_RESPONSE 0x108
const char *esp_err_to_name(esp_err_t code);
/* Aborts on the host too -- a failed ESP_ERROR_CHECK is a bug worth crashing
* on in the simulator exactly as it is on the device. */
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr);
#define ESP_ERROR_CHECK(x) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
esp_error_check_failed(err_rc_, __FILE__, __LINE__, #x); \
} \
} while (0)
#endif
+28
View File
@@ -0,0 +1,28 @@
/*
* Host stand-in for ESP-IDF's esp_log.h -- see compat/esp_err.h for why these
* shims exist. Logs go to stderr so that stdout stays clean for anything the
* simulator wants to emit as data.
*/
#ifndef ESP_LOG_H
#define ESP_LOG_H
#include <stdio.h>
/* ANSI colours matched to idf.py monitor, so simulator output reads the same
* way device output does. */
#define ESP_LOG_COLOR_E "\033[0;31m"
#define ESP_LOG_COLOR_W "\033[0;33m"
#define ESP_LOG_COLOR_I "\033[0;32m"
#define ESP_LOG_COLOR_D "\033[0;36m"
#define ESP_LOG_RESET "\033[0m"
#define ESP_LOG_LEVEL_LOCAL(colour, letter, tag, fmt, ...) \
fprintf(stderr, colour letter " (sim) %s: " fmt ESP_LOG_RESET "\n", tag, ##__VA_ARGS__)
#define ESP_LOGE(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_E, "E", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGW(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_W, "W", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGI(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_I, "I", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGD(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "D", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGV(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "V", tag, fmt, ##__VA_ARGS__)
#endif
+1530
View File
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
/*
* Host implementation of the firmware's lvgl_port.h contract.
*
* The firmware version (main/ui/lvgl_port.c) is built on FreeRTOS tasks and
* esp_timer. This file provides the same six entry points on pthreads so that
* main/ui/ui_manager.c -- the file whose layout we actually iterate on --
* compiles and runs unchanged on the desktop.
*
* The one deliberate behavioural difference is lvgl_port_start(): on the
* device it spawns the task that calls lv_timer_handler(), whereas here the
* simulator's own main loop drives LVGL. See the comment on that function.
*/
#include <pthread.h>
#include <stdbool.h>
#include <time.h>
#include "esp_log.h"
#include "lvgl.h"
#include "lvgl_port.h"
static const char *TAG = "lvgl_port_sim";
static pthread_mutex_t lvgl_mutex;
static bool mutex_ready = false;
static volatile bool paused = false;
/* LVGL's millisecond time base. The firmware drives this from a 2ms esp_timer
* calling lv_tick_inc(); on the host a monotonic clock read is both simpler
* and more accurate. */
static uint32_t tick_get_cb(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint32_t)(ts.tv_sec * 1000U + ts.tv_nsec / 1000000U);
}
esp_err_t lvgl_port_init(void)
{
/*
* Recursive, matching the device: lv_timer_handler() runs callbacks that
* may re-enter code which also takes the lock. Keeping the same semantics
* here means a deadlock introduced in shared UI code reproduces in the
* simulator instead of only showing up on hardware.
*/
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
if (pthread_mutex_init(&lvgl_mutex, &attr) != 0) {
pthread_mutexattr_destroy(&attr);
return ESP_ERR_NO_MEM;
}
pthread_mutexattr_destroy(&attr);
mutex_ready = true;
lv_init();
lv_tick_set_cb(tick_get_cb);
ESP_LOGI(TAG, "LVGL %d.%d.%d initialized (simulator)",
lv_version_major(), lv_version_minor(), lv_version_patch());
return ESP_OK;
}
/*
* No-op by design.
*
* On the device this starts the tick timer and the LVGL service task. Here the
* tick is a clock callback installed in lvgl_port_init(), and lv_timer_handler()
* is called from the simulator's main loop -- running it from a second thread
* as well would mean two threads rendering into one framebuffer.
*
* It still exists so that shared start-up code can call it unconditionally.
*/
esp_err_t lvgl_port_start(void)
{
return ESP_OK;
}
void lvgl_port_pause(void)
{
paused = true;
}
void lvgl_port_resume(void)
{
paused = false;
}
bool lvgl_port_is_paused(void)
{
return paused;
}
bool lvgl_port_lock(int timeout_ms)
{
if (!mutex_ready) {
return false;
}
if (timeout_ms < 0) {
return pthread_mutex_lock(&lvgl_mutex) == 0;
}
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
return pthread_mutex_timedlock(&lvgl_mutex, &deadline) == 0;
}
void lvgl_port_unlock(void)
{
if (mutex_ready) {
pthread_mutex_unlock(&lvgl_mutex);
}
}
+398
View File
@@ -0,0 +1,398 @@
/*
* Desktop simulator entry point.
*
* Runs the real firmware UI (main/ui/ui_manager.c) against desktop LVGL at the
* panel's exact geometry and colour depth, so layout work does not need a
* flash cycle. Two modes:
*
* ./thermostat_sim interactive SDL window, mouse = touch
* ./thermostat_sim --png shot.png render one frame headless, write PNG
*
* The PNG mode exists so a layout change can be inspected without a human
* looking at a screen -- useful in CI, and it is how UI changes get checked
* during development.
*
* What this does NOT simulate: PSRAM bandwidth, the bounce-buffer refill ISR,
* or the RGB panel's scan timing. The left-edge artefacts described in the
* README are invisible here by construction. This tool answers "does the
* layout look right", never "does the panel keep up".
*/
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "lvgl.h"
#include "lodepng.h"
#include "ha_data.h"
#include "ha_template.h"
#include "lvgl_port.h"
#include "ui_manager.h"
/* Must match the physical panel: main/drivers/display/display_rgb.h. A
* simulator at a different resolution would validate a layout that does not
* fit the hardware. */
#define PANEL_H_RES 800
#define PANEL_V_RES 480
static const char *TAG = "sim";
/* ---------------------------------------------------------------- headless */
static uint8_t *headless_fb = NULL;
static void headless_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
{
/* Rendering happens straight into headless_fb (DIRECT mode), so there is
* nothing to copy -- just tell LVGL the "transfer" is done. */
(void)area;
(void)px_map;
lv_display_flush_ready(disp);
}
static lv_display_t *headless_display_create(void)
{
const size_t fb_bytes = (size_t)PANEL_H_RES * PANEL_V_RES * 2; /* RGB565 */
headless_fb = malloc(fb_bytes);
if (headless_fb == NULL) {
fprintf(stderr, "%s: cannot allocate %zu byte framebuffer\n", TAG, fb_bytes);
return NULL;
}
memset(headless_fb, 0, fb_bytes);
lv_display_t *disp = lv_display_create(PANEL_H_RES, PANEL_V_RES);
if (disp == NULL) {
return NULL;
}
lv_display_set_flush_cb(disp, headless_flush_cb);
/* DIRECT mode with one full-screen buffer, matching the firmware's render
* mode so widget clipping and partial-redraw behaviour line up. */
lv_display_set_buffers(disp, headless_fb, NULL, fb_bytes,
LV_DISPLAY_RENDER_MODE_DIRECT);
return disp;
}
/* RGB565 little-endian -> RGBA8888, then out to a PNG via LVGL's bundled
* lodepng. Channels are scaled (not just shifted) so a saturated 0x1F maps to
* 255 rather than 248 -- otherwise every screenshot reads slightly dark. */
static int headless_write_png(const char *path)
{
const size_t px_count = (size_t)PANEL_H_RES * PANEL_V_RES;
uint8_t *rgba = malloc(px_count * 4);
if (rgba == NULL) {
fprintf(stderr, "%s: cannot allocate RGBA conversion buffer\n", TAG);
return -1;
}
const uint16_t *src = (const uint16_t *)headless_fb;
for (size_t i = 0; i < px_count; i++) {
const uint16_t p = src[i];
const uint8_t r5 = (uint8_t)((p >> 11) & 0x1F);
const uint8_t g6 = (uint8_t)((p >> 5) & 0x3F);
const uint8_t b5 = (uint8_t)(p & 0x1F);
rgba[i * 4 + 0] = (uint8_t)((r5 * 255 + 15) / 31);
rgba[i * 4 + 1] = (uint8_t)((g6 * 255 + 31) / 63);
rgba[i * 4 + 2] = (uint8_t)((b5 * 255 + 15) / 31);
rgba[i * 4 + 3] = 255;
}
/*
* Encode to memory and write with stdio, rather than lodepng_encode32_file().
* LVGL patches its bundled lodepng to do file I/O through lv_fs, which
* would mean registering an LVGL filesystem driver and passing
* drive-letter paths like "A:/tmp/shot.png". Not worth it to save an
* fwrite.
*/
uint8_t *png = NULL;
size_t png_len = 0;
const unsigned err = lodepng_encode32(&png, &png_len, rgba, PANEL_H_RES, PANEL_V_RES);
free(rgba);
if (err != 0) {
fprintf(stderr, "%s: PNG encode failed: %s\n", TAG, lodepng_error_text(err));
return -1;
}
FILE *f = fopen(path, "wb");
if (f == NULL) {
perror(path);
free(png); /* LV_USE_STDLIB_MALLOC is CLIB here, so lv_free == free */
return -1;
}
const size_t written = fwrite(png, 1, png_len, f);
const bool short_write = (written != png_len);
const bool close_failed = (fclose(f) != 0);
free(png);
if (short_write || close_failed) {
fprintf(stderr, "%s: writing %s failed\n", TAG, path);
return -1;
}
printf("wrote %s (%dx%d, %zu bytes)\n", path, PANEL_H_RES, PANEL_V_RES, png_len);
return 0;
}
/* ------------------------------------------------------------------- data */
/* The state the UI is currently showing. Snapshots load into this, and the
* light callback mutates it so the simulator responds to clicks with no Home
* Assistant present. */
static ha_data_t sim_data;
static char *read_file(const char *path)
{
FILE *f = fopen(path, "rb");
if (f == NULL) {
perror(path);
return NULL;
}
fseek(f, 0, SEEK_END);
const long len = ftell(f);
rewind(f);
if (len < 0) {
fclose(f);
return NULL;
}
char *buf = malloc((size_t)len + 1);
if (buf == NULL) {
fclose(f);
return NULL;
}
const size_t got = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[got] = '\0';
return buf;
}
static bool load_snapshot(const char *path, bool forecast)
{
char *json = read_file(path);
if (json == NULL) {
return false;
}
const bool ok = forecast ? ha_parse_forecast_json(json, &sim_data)
: ha_parse_json(json, &sim_data);
free(json);
if (!ok) {
fprintf(stderr, "%s: %s is not valid %s JSON\n", TAG, path,
forecast ? "forecast" : "state");
}
return ok;
}
/*
* Enough of a populated screen to lay out against when no snapshot is given.
* Deliberately includes the awkward cases: the wind sensor is offline (as it
* genuinely is), one light is unavailable, and one is dimmed.
*/
static void apply_mock_data(void)
{
memset(&sim_data, 0, sizeof(sim_data));
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
sim_data.indoor_temp_f = (ha_value_t){ 78.1f, true };
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
/* wind/gust/wdir left invalid on purpose -- that is the real state */
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
snprintf(sim_data.sunset, sizeof(sim_data.sunset), "7:45 PM");
snprintf(sim_data.clock, sizeof(sim_data.clock), "10:25 AM");
snprintf(sim_data.date, sizeof(sim_data.date), "Tue Aug 4");
sim_data.sun_up = true;
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
sim_data.lights[i].state = HA_LIGHT_OFF;
sim_data.lights[i].brightness = -1;
}
sim_data.lights[0].state = HA_LIGHT_ON;
sim_data.lights[0].brightness = 178; /* 70% */
sim_data.lights[4].state = HA_LIGHT_UNAVAILABLE;
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
static const char *const cond[HA_FORECAST_DAYS] = {
"partlycloudy", "sunny", "sunny", "clear-night", "rainy"
};
static const float hi[HA_FORECAST_DAYS] = { 80, 80, 82, 82, 74 };
static const float lo[HA_FORECAST_DAYS] = { 70, 67, 68, 69, 63 };
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
snprintf(sim_data.forecast[i].day, sizeof(sim_data.forecast[i].day), "%s", days[i]);
snprintf(sim_data.forecast[i].condition, sizeof(sim_data.forecast[i].condition),
"%s", cond[i]);
sim_data.forecast[i].high_f = (ha_value_t){ hi[i], true };
sim_data.forecast[i].low_f = (ha_value_t){ lo[i], true };
}
sim_data.forecast_count = HA_FORECAST_DAYS;
}
/*
* Stands in for the firmware's HTTP command path. Applying the change locally
* means tiles respond to clicks in the simulator; on the device the real state
* comes back from Home Assistant on the next poll instead.
*/
static void sim_light_cmd(int index, bool on, int16_t brightness)
{
if (index < 0 || index >= HA_LIGHT_COUNT) {
return;
}
fprintf(stderr, "light cmd: %-52s %s brightness=%d\n",
HA_LIGHTS[index].entity_id, on ? "ON " : "OFF", brightness);
sim_data.lights[index].state = on ? HA_LIGHT_ON : HA_LIGHT_OFF;
if (brightness >= 0) {
sim_data.lights[index].brightness = brightness;
} else if (on && sim_data.lights[index].brightness < 0) {
/* Turning on without a brightness: HA restores the previous level.
* Full is a reasonable stand-in for that here. */
sim_data.lights[index].brightness = 255;
}
ui_update(&sim_data);
}
/* ------------------------------------------------------------------- main */
static void print_usage(const char *argv0)
{
printf("usage: %s [--png FILE] [--settle-ms N] [--data FILE] [--forecast FILE]\n"
"\n"
" (no arguments) interactive SDL window, mouse acts as touch\n"
" --png FILE render one frame headless and write it to FILE\n"
" --settle-ms N ms of LVGL time before the PNG capture (default 400)\n"
" --data FILE state JSON as returned by HA's /api/template\n"
" --forecast FILE response JSON from weather/get_forecasts\n"
" --print-template emit the Jinja template the firmware uses, then exit\n"
"\n"
"Without --data a built-in mock is used. Capture real snapshots with\n"
"tools/ha_snapshot.py to iterate against states that actually occur.\n",
argv0);
}
int main(int argc, char **argv)
{
const char *png_path = NULL;
const char *data_path = NULL;
const char *forecast_path = NULL;
int settle_ms = 400;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--png") == 0 && i + 1 < argc) {
png_path = argv[++i];
} else if (strcmp(argv[i], "--settle-ms") == 0 && i + 1 < argc) {
settle_ms = atoi(argv[++i]);
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
data_path = argv[++i];
} else if (strcmp(argv[i], "--forecast") == 0 && i + 1 < argc) {
forecast_path = argv[++i];
} else if (strcmp(argv[i], "--print-template") == 0) {
/*
* Lets tools/ha_snapshot.py capture snapshots using the exact
* template the firmware compiles, instead of a hand-copied
* duplicate that silently drifts the first time an entity ID
* changes. The preprocessor is the single source of truth.
*/
printf("%s\n", HA_STATE_TEMPLATE);
return 0;
} else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
print_usage(argv[0]);
return 0;
} else {
fprintf(stderr, "unrecognised argument: %s\n", argv[i]);
print_usage(argv[0]);
return 2;
}
}
ESP_ERROR_CHECK(lvgl_port_init());
lv_display_t *disp = NULL;
if (png_path != NULL) {
disp = headless_display_create();
} else {
disp = lv_sdl_window_create(PANEL_H_RES, PANEL_V_RES);
if (disp != NULL) {
lv_sdl_window_set_title(disp, "thermostat-hw · CrowPanel 7\" 800x480");
lv_sdl_mouse_create(); /* mouse stands in for the GT911 */
}
}
if (disp == NULL) {
fprintf(stderr, "%s: display creation failed\n", TAG);
return 1;
}
ui_init();
ESP_ERROR_CHECK(lvgl_port_start());
ui_set_light_cb(sim_light_cmd);
/*
* A snapshot replaces the mock wholesale rather than layering onto it --
* mixing the two would show a screen that never occurs in reality.
*/
if (data_path != NULL) {
memset(&sim_data, 0, sizeof(sim_data));
if (!load_snapshot(data_path, false)) {
return 1;
}
} else {
apply_mock_data();
}
if (forecast_path != NULL && !load_snapshot(forecast_path, true)) {
return 1;
}
ui_update(&sim_data);
ui_set_status(data_path != NULL ? data_path : "simulator - mock data");
if (png_path != NULL) {
/*
* Let timers and any entry animation settle before capturing,
* otherwise the screenshot catches a half-built frame.
*/
const uint32_t deadline_ms = lv_tick_get() + (uint32_t)settle_ms;
while (lv_tick_get() < deadline_ms) {
lv_timer_handler();
usleep(5000);
}
lv_refr_now(disp); /* force a synchronous full render into headless_fb */
return headless_write_png(png_path) == 0 ? 0 : 1;
}
/* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the
* process, so this loop does not need its own termination condition. */
while (true) {
lv_timer_handler();
usleep(5000);
}
return 0;
}
+1
View File
@@ -0,0 +1 @@
{"out_t": 61.4, "out_h": 94.0, "in_t": 72.3, "in_h": 58.0, "dew": 59.0, "press": 29.71, "rain_d": 0.42, "rain_r": 0.11, "wind": null, "gust": null, "wdir": null, "cond": "rainy", "sunrise": "6:05 AM", "sunset": "7:45 PM", "sun_up": false, "clock": "9:42 PM", "date": "Tue Aug 4", "lights": [{"s": "on", "b": 255}, {"s": "on", "b": 128}, {"s": "on", "b": 40}, {"s": "off", "b": null}, {"s": "unavailable", "b": null}, {"s": "on", "b": null}]}
+1
View File
@@ -0,0 +1 @@
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"partlycloudy","datetime":"2026-08-04T19:00:00+00:00","wind_bearing":248.4,"uv_index":10.5,"temperature":80,"templow":70,"wind_speed":9.63,"precipitation":0.0,"humidity":72},{"condition":"sunny","datetime":"2026-08-05T19:00:00+00:00","wind_bearing":263.9,"uv_index":10.0,"temperature":80,"templow":67,"wind_speed":10.94,"precipitation":0.0,"humidity":70},{"condition":"sunny","datetime":"2026-08-06T19:00:00+00:00","wind_bearing":278.8,"uv_index":9.9,"temperature":82,"templow":68,"wind_speed":10.31,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":285.6,"temperature":82,"templow":69,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":268.9,"temperature":82,"templow":68,"wind_speed":7.15,"precipitation":0.0,"humidity":72},{"condition":"clear-night","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":212.3,"temperature":81,"templow":69,"wind_speed":8.08,"precipitation":0.0,"humidity":74}]}}}
+1
View File
@@ -0,0 +1 @@
{"out_t":76.6,"out_h":73.0,"in_t":78.6,"in_h":69.0,"dew":67.28,"press":29.899,"rain_d":0.0,"rain_r":0.0,"wind":null,"gust":null,"wdir":null,"cond":"partlycloudy","sunrise":"6:05 AM","sunset":"7:45 PM","sun_up":true,"clock":"11:32 AM","date":"Tue Aug 4","lights":[{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"unavailable","b":null},{"s":"off","b":null}]}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
List the Home Assistant entities this project cares about.
Used to pin down entity IDs before wiring them into the firmware, and to
re-check them after changing something in HA -- entity IDs are not stable
across integration renames, and a wrong one fails silently as "unavailable".
export HA_TOKEN='...' # Profile -> Security -> Long-lived access token
python3 tools/ha_discover.py
The token is read from the environment and never printed or written anywhere.
--host HOST default 192.168.1.134
--all every entity, not just the interesting domains
--json raw JSON for the matched entities, including attributes
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
# The domains this display draws from. "sensor" is where a weather station's
# individual measurements land; "weather" is the forecast entity; "sun" gives
# sunrise/sunset; "light" is what we control.
DEFAULT_DOMAINS = ("weather", "sun", "light", "sensor", "binary_sensor", "switch")
def fetch(host: str, token: str, path: str):
req = urllib.request.Request(
f"http://{host}:8123{path}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.1.134")
ap.add_argument("--all", action="store_true", help="include every domain")
ap.add_argument("--json", action="store_true", help="dump raw JSON with attributes")
args = ap.parse_args()
token = os.environ.get("HA_TOKEN")
if not token:
print("HA_TOKEN is not set.\n"
"In Home Assistant: click your user (bottom left) -> Security tab ->\n"
"Long-lived access tokens -> Create token. Then:\n"
" export HA_TOKEN='paste-it-here'",
file=sys.stderr)
return 2
try:
states = fetch(args.host, token, "/api/states")
except urllib.error.HTTPError as e:
detail = "token rejected -- create a new long-lived token" if e.code == 401 else e.reason
print(f"HTTP {e.code} from {args.host}: {detail}", file=sys.stderr)
return 1
except urllib.error.URLError as e:
print(f"Cannot reach Home Assistant at {args.host}:8123 -- {e.reason}", file=sys.stderr)
return 1
matched = [
s for s in states
if args.all or s["entity_id"].split(".", 1)[0] in DEFAULT_DOMAINS
]
matched.sort(key=lambda s: s["entity_id"])
if args.json:
print(json.dumps(matched, indent=2))
return 0
print(f"{len(matched)} entities on {args.host} "
f"(of {len(states)} total)\n")
by_domain: dict[str, list] = {}
for s in matched:
by_domain.setdefault(s["entity_id"].split(".", 1)[0], []).append(s)
for domain in sorted(by_domain):
entities = by_domain[domain]
print(f"=== {domain} ({len(entities)}) " + "=" * (46 - len(domain)))
for s in entities:
attrs = s.get("attributes", {})
name = attrs.get("friendly_name", "")
unit = attrs.get("unit_of_measurement", "")
state = s["state"]
if len(state) > 28:
state = state[:25] + "..."
print(f" {s['entity_id']:<48} {state:>30} {unit:<6} {name}")
print()
# The forecast is not in /api/states -- it moved to a service call that
# returns a response. Flag which entities support it so we know what to ask.
weather = by_domain.get("weather", [])
if weather:
print("Forecast note: weather.* forecasts come from the")
print(" weather.get_forecasts service (POST /api/services/weather/get_forecasts")
print(" with ?return_response), not from /api/states. Candidates:")
for s in weather:
print(f" {s['entity_id']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Capture Home Assistant responses to disk for the simulator to render.
Iterating on layout against live data is awkward: the interesting states --
rain falling, a light dimmed, a sensor dropping out -- do not happen on
demand, and live data changes underneath a before/after comparison. Snapshots
are reproducible and can be hand-edited to produce a state that has not
occurred yet.
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py # -> sim/snapshots/live.json
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/live.json \
--forecast sim/snapshots/live.forecast.json
The template is not duplicated here. It is read from the simulator binary via
--print-template, which is compiled from main/api/ha_template.h, so a snapshot
can never be captured with a template the firmware does not use.
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SIM_BIN = REPO / "sim" / "build" / "thermostat_sim"
SNAP_DIR = REPO / "sim" / "snapshots"
def load_template() -> str:
if not SIM_BIN.exists():
sys.exit(f"{SIM_BIN} not built.\n"
" cmake -S sim -B sim/build -G Ninja && cmake --build sim/build")
out = subprocess.run([str(SIM_BIN), "--print-template"],
capture_output=True, text=True, check=True)
return out.stdout.strip()
def call(host: str, token: str, path: str, body: dict | str):
data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
req = urllib.request.Request(
f"http://{host}:8123{path}",
data=data,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
try:
return urllib.request.urlopen(req, timeout=20).read().decode()
except urllib.error.HTTPError as e:
sys.exit(f"HTTP {e.code} on {path}: {e.read().decode()[:400]}")
except urllib.error.URLError as e:
sys.exit(f"Cannot reach Home Assistant at {host}:8123 -- {e.reason}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.1.134")
ap.add_argument("--name", default="live", help="snapshot basename")
args = ap.parse_args()
token = os.environ.get("HA_TOKEN")
if not token:
sys.exit("HA_TOKEN is not set. See tools/ha_discover.py for how to make one.")
SNAP_DIR.mkdir(parents=True, exist_ok=True)
state = call(args.host, token, "/api/template", {"template": load_template()})
# Parse before writing: a template that renders to broken JSON should fail
# here rather than at the point some future run tries to render it.
try:
parsed = json.loads(state)
except json.JSONDecodeError as e:
sys.exit(f"Template rendered invalid JSON ({e}):\n{state[:500]}")
state_path = SNAP_DIR / f"{args.name}.json"
state_path.write_text(state)
forecast = call(args.host, token,
"/api/services/weather/get_forecasts?return_response",
{"entity_id": "weather.forecast_home", "type": "daily"})
fc_path = SNAP_DIR / f"{args.name}.forecast.json"
fc_path.write_text(forecast)
print(f"wrote {state_path.relative_to(REPO)} ({len(state)} bytes)")
print(f"wrote {fc_path.relative_to(REPO)} ({len(forecast)} bytes)")
dead = [k for k, v in parsed.items() if v is None]
if dead:
print(f"\nunavailable sensors in this snapshot: {', '.join(dead)}")
lights = parsed.get("lights", [])
print(f"lights: {sum(1 for l in lights if l.get('s') == 'on')} on, "
f"{sum(1 for l in lights if l.get('s') == 'off')} off, "
f"{sum(1 for l in lights if l.get('s') not in ('on', 'off'))} unavailable")
print(f"\n ./sim/build/thermostat_sim --data {state_path.relative_to(REPO)} \\")
print(f" --forecast {fc_path.relative_to(REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())