diff --git a/.gitignore b/.gitignore index 83b4422..96cbccc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index c89cbbf..f9149e9 100644 --- a/README.md +++ b/README.md @@ -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()`. diff --git a/TODO.md b/TODO.md index 3d9a1ae..6f81a3b 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b94edea..43547ac 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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" diff --git a/main/api/ha_client.c b/main/api/ha_client.c new file mode 100644 index 0000000..63ebd11 --- /dev/null +++ b/main/api/ha_client.c @@ -0,0 +1,263 @@ +/* + * Home Assistant REST transport. See ha_client.h for the contract and why + * this is plain HTTP. + */ + +#include +#include + +#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); +} diff --git a/main/api/ha_client.h b/main/api/ha_client.h new file mode 100644 index 0000000..2efb20e --- /dev/null +++ b/main/api/ha_client.h @@ -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 +#include + +#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 diff --git a/main/api/ha_config.h b/main/api/ha_config.h new file mode 100644 index 0000000..334bb03 --- /dev/null +++ b/main/api/ha_config.h @@ -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 diff --git a/main/api/ha_data.c b/main/api/ha_data.c new file mode 100644 index 0000000..3d6d4ea --- /dev/null +++ b/main/api/ha_data.c @@ -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 +#include +#include +#include + +#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": { "": { "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]; +} diff --git a/main/api/ha_data.h b/main/api/ha_data.h new file mode 100644 index 0000000..5c9c6ec --- /dev/null +++ b/main/api/ha_data.h @@ -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 +#include + +#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 diff --git a/main/api/ha_template.h b/main/api/ha_template.h new file mode 100644 index 0000000..02e3bc4 --- /dev/null +++ b/main/api/ha_template.h @@ -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 diff --git a/main/app_config.h.example b/main/app_config.h.example index c61b9a6..e87f771 100644 --- a/main/app_config.h.example +++ b/main/app_config.h.example @@ -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 diff --git a/main/main.c b/main/main.c index d62cdf1..1c658be 100644 --- a/main/main.c +++ b/main/main.c @@ -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); + ui_update(&ha_data); + ui_set_stale(false); - char detail[64]; - snprintf(detail, sizeof(detail), "%.0f%% humidity", - weather.has_humidity ? weather.humidity : 0.0f); + if (on_fallback) { + ui_set_status("Home Assistant unreachable - cloud weather only"); + } else { + wifi_get_status(line, sizeof(line)); + ui_set_status(line); + } - /* 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); - - 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"); } diff --git a/main/ui/ui_manager.c b/main/ui/ui_manager.c index 586f114..5c29a32 100644 --- a/main/ui/ui_manager.c +++ b/main/ui/ui_manager.c @@ -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 #include #include @@ -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); } diff --git a/main/ui/ui_manager.h b/main/ui/ui_manager.h index 38fbb8b..08f0f2c 100644 --- a/main/ui/ui_manager.h +++ b/main/ui/ui_manager.h @@ -3,19 +3,47 @@ #include #include + +#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 diff --git a/project_structure.txt b/project_structure.txt index 323088c..3d50559 100644 --- a/project_structure.txt +++ b/project_structure.txt @@ -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. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index acc07bc..0202c60 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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 --- diff --git a/sim/CMakeLists.txt b/sim/CMakeLists.txt new file mode 100644 index 0000000..7879b95 --- /dev/null +++ b/sim/CMakeLists.txt @@ -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) diff --git a/sim/README.md b/sim/README.md new file mode 100644 index 0000000..b084e7f --- /dev/null +++ b/sim/README.md @@ -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`. diff --git a/sim/compat/esp_check.h b/sim/compat/esp_check.h new file mode 100644 index 0000000..3e610ef --- /dev/null +++ b/sim/compat/esp_check.h @@ -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 diff --git a/sim/compat/esp_compat.c b/sim/compat/esp_compat.c new file mode 100644 index 0000000..a108518 --- /dev/null +++ b/sim/compat/esp_compat.c @@ -0,0 +1,28 @@ +#include +#include + +#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(); +} diff --git a/sim/compat/esp_err.h b/sim/compat/esp_err.h new file mode 100644 index 0000000..26b9c07 --- /dev/null +++ b/sim/compat/esp_err.h @@ -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 +#include + +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 diff --git a/sim/compat/esp_log.h b/sim/compat/esp_log.h new file mode 100644 index 0000000..a406f74 --- /dev/null +++ b/sim/compat/esp_log.h @@ -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 + +/* 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 diff --git a/sim/lv_conf.h b/sim/lv_conf.h new file mode 100644 index 0000000..35f4f7a --- /dev/null +++ b/sim/lv_conf.h @@ -0,0 +1,1530 @@ +/** + * @file lv_conf.h + * Configuration file for v9.5.0 + */ + +/* + * Copy this file as `lv_conf.h` + * 1. simply next to `lvgl` folder + * 2. or to any other place and + * - define `LV_CONF_INCLUDE_SIMPLE`; + * - add the path as an include path. + */ + +/* clang-format off */ +#if 1 /* Set this to "1" to enable content */ + +#ifndef LV_CONF_H +#define LV_CONF_H + +/* If you need to include anything here, do it inside the `__ASSEMBLY__` guard */ +#if 0 && defined(__ASSEMBLY__) +#include "my_include.h" +#endif + +/*==================== + COLOR SETTINGS + *====================*/ + +/** Color depth: 1 (I1), 8 (L8), 16 (RGB565), 24 (RGB888), 32 (XRGB8888) */ +#define LV_COLOR_DEPTH 16 + +/*========================= + STDLIB WRAPPER SETTINGS + *=========================*/ + +/** Possible values + * - LV_STDLIB_BUILTIN: LVGL's built in implementation + * - LV_STDLIB_CLIB: Standard C functions, like malloc, strlen, etc + * - LV_STDLIB_MICROPYTHON: MicroPython implementation + * - LV_STDLIB_RTTHREAD: RT-Thread implementation + * - LV_STDLIB_CUSTOM: Implement the functions externally + */ +/* CLIB, not BUILTIN as on the device: the firmware deliberately uses LVGL's + * fixed pool out of PSRAM, but on the host a fixed pool only adds a way for + * screenshot encoding to run out of memory. lv_malloc/lv_free become + * malloc/free, so lodepng's output buffer is safe to hand to free(). */ +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB + +/** Possible values + * - LV_STDLIB_BUILTIN: LVGL's built in implementation + * - LV_STDLIB_CLIB: Standard C functions, like malloc, strlen, etc + * - LV_STDLIB_MICROPYTHON: MicroPython implementation + * - LV_STDLIB_RTTHREAD: RT-Thread implementation + * - LV_STDLIB_CUSTOM: Implement the functions externally + */ +#define LV_USE_STDLIB_STRING LV_STDLIB_BUILTIN + +/** Possible values + * - LV_STDLIB_BUILTIN: LVGL's built in implementation + * - LV_STDLIB_CLIB: Standard C functions, like malloc, strlen, etc + * - LV_STDLIB_MICROPYTHON: MicroPython implementation + * - LV_STDLIB_RTTHREAD: RT-Thread implementation + * - LV_STDLIB_CUSTOM: Implement the functions externally + */ +#define LV_USE_STDLIB_SPRINTF LV_STDLIB_BUILTIN + +#define LV_STDINT_INCLUDE +#define LV_STDDEF_INCLUDE +#define LV_STDBOOL_INCLUDE +#define LV_INTTYPES_INCLUDE +#define LV_LIMITS_INCLUDE +#define LV_STDARG_INCLUDE + +#if LV_USE_STDLIB_MALLOC == LV_STDLIB_BUILTIN + /** Size of memory available for `lv_malloc()` in bytes (>= 2kB) */ + #define LV_MEM_SIZE (4 * 1024 * 1024U) /**< [bytes] */ + + /** Size of the memory expand for `lv_malloc()` in bytes */ + #define LV_MEM_POOL_EXPAND_SIZE 0 + + /** Set an address for the memory pool instead of allocating it as a normal array. Can be in external SRAM too. */ + #define LV_MEM_ADR 0 /**< 0: unused*/ + /* Instead of an address give a memory allocator that will be called to get a memory pool for LVGL. E.g. my_malloc */ + #if LV_MEM_ADR == 0 + #undef LV_MEM_POOL_INCLUDE + #undef LV_MEM_POOL_ALLOC + #endif +#endif /*LV_USE_STDLIB_MALLOC == LV_STDLIB_BUILTIN*/ + +/*==================== + HAL SETTINGS + *====================*/ + +/** Default display refresh, input device read and animation step period. */ +#define LV_DEF_REFR_PERIOD 33 /**< [ms] */ + +/** Default Dots Per Inch. Used to initialize default sizes such as widgets sized, style paddings. + * (Not so important, you can adjust it to modify default sizes and spaces.) */ +#define LV_DPI_DEF 130 /**< [px/inch] */ + +/*================= + * OPERATING SYSTEM + *=================*/ +/** Select operating system to use. Possible options: + * - LV_OS_NONE + * - LV_OS_PTHREAD + * - LV_OS_FREERTOS + * - LV_OS_CMSIS_RTOS2 + * - LV_OS_RTTHREAD + * - LV_OS_WINDOWS + * - LV_OS_MQX + * - LV_OS_SDL2 + * - LV_OS_CUSTOM */ +#define LV_USE_OS LV_OS_NONE + +#if LV_USE_OS == LV_OS_CUSTOM + #define LV_OS_CUSTOM_INCLUDE +#endif +#if LV_USE_OS == LV_OS_FREERTOS + /* + * Unblocking an RTOS task with a direct notification is 45% faster and uses less RAM + * than unblocking a task using an intermediary object such as a binary semaphore. + * RTOS task notifications can only be used when there is only one task that can be the recipient of the event. + */ + #define LV_USE_FREERTOS_TASK_NOTIFY 1 +#endif + +/*======================== + * RENDERING CONFIGURATION + *========================*/ + +/** Align stride of all layers and images to this bytes */ +#define LV_DRAW_BUF_STRIDE_ALIGN 1 + +/** Align start address of draw_buf addresses to this bytes*/ +#define LV_DRAW_BUF_ALIGN 4 + +/** Using matrix for transformations. + * Requirements: + * - `LV_USE_MATRIX = 1`. + * - Rendering engine needs to support 3x3 matrix transformations. */ +#define LV_DRAW_TRANSFORM_USE_MATRIX 0 + +/* If a widget has `style_opa < 255` (not `bg_opa`, `text_opa` etc) or not NORMAL blend mode + * it is buffered into a "simple" layer before rendering. The widget can be buffered in smaller chunks. + * "Transformed layers" (if `transform_angle/zoom` are set) use larger buffers + * and can't be drawn in chunks. */ + +/** The target buffer size for simple layer chunks. */ +#define LV_DRAW_LAYER_SIMPLE_BUF_SIZE (24 * 1024) /**< [bytes]*/ + +/* Limit the max allocated memory for simple and transformed layers. + * It should be at least `LV_DRAW_LAYER_SIMPLE_BUF_SIZE` sized but if transformed layers are also used + * it should be enough to store the largest widget too (width x height x 4 area). + * Set it to 0 to have no limit. */ +#define LV_DRAW_LAYER_MAX_MEMORY 0 /**< No limit by default [bytes]*/ + +/** Stack size of drawing thread. + * NOTE: If FreeType or ThorVG is enabled, it is recommended to set it to 32KB or more. + */ +#define LV_DRAW_THREAD_STACK_SIZE (8 * 1024) /**< [bytes]*/ + +/** Thread priority of the drawing task. + * Higher values mean higher priority. + * Can use values from lv_thread_prio_t enum in lv_os.h: LV_THREAD_PRIO_LOWEST, + * LV_THREAD_PRIO_LOW, LV_THREAD_PRIO_MID, LV_THREAD_PRIO_HIGH, LV_THREAD_PRIO_HIGHEST + * Make sure the priority value aligns with the OS-specific priority levels. + * On systems with limited priority levels (e.g., FreeRTOS), a higher value can improve + * rendering performance but might cause other tasks to starve. */ +#define LV_DRAW_THREAD_PRIO LV_THREAD_PRIO_HIGH + +#define LV_USE_DRAW_SW 1 +#if LV_USE_DRAW_SW == 1 + /* + * Selectively disable color format support in order to reduce code size. + * NOTE: some features use certain color formats internally, e.g. + * - gradients use RGB888 + * - bitmaps with transparency may use ARGB8888 + */ + #define LV_DRAW_SW_SUPPORT_RGB565 1 + #define LV_DRAW_SW_SUPPORT_RGB565_SWAPPED 1 + #define LV_DRAW_SW_SUPPORT_RGB565A8 1 + #define LV_DRAW_SW_SUPPORT_RGB888 1 + #define LV_DRAW_SW_SUPPORT_XRGB8888 1 + #define LV_DRAW_SW_SUPPORT_ARGB8888 1 + #define LV_DRAW_SW_SUPPORT_ARGB8888_PREMULTIPLIED 1 + #define LV_DRAW_SW_SUPPORT_L8 1 + #define LV_DRAW_SW_SUPPORT_AL88 1 + #define LV_DRAW_SW_SUPPORT_A8 1 + #define LV_DRAW_SW_SUPPORT_I1 1 + + /* The threshold of the luminance to consider a pixel as + * active in indexed color format */ + #define LV_DRAW_SW_I1_LUM_THRESHOLD 127 + + /** Set number of draw units. + * - > 1 requires operating system to be enabled in `LV_USE_OS`. + * - > 1 means multiple threads will render the screen in parallel. */ + #define LV_DRAW_SW_DRAW_UNIT_CNT 1 + + /** Use Arm-2D to accelerate software (sw) rendering. */ + #define LV_USE_DRAW_ARM2D_SYNC 0 + + /** Enable native helium assembly to be compiled. */ + #define LV_USE_NATIVE_HELIUM_ASM 0 + + /** + * - 0: Use a simple renderer capable of drawing only simple rectangles with gradient, images, text, and straight lines only. + * - 1: Use a complex renderer capable of drawing rounded corners, shadow, skew lines, and arcs too. */ + #define LV_DRAW_SW_COMPLEX 1 + + #if LV_DRAW_SW_COMPLEX == 1 + /** Allow buffering some shadow calculation. + * LV_DRAW_SW_SHADOW_CACHE_SIZE is the maximum shadow size to buffer, where shadow size is + * `shadow_width + radius`. Caching has LV_DRAW_SW_SHADOW_CACHE_SIZE^2 RAM cost. */ + #define LV_DRAW_SW_SHADOW_CACHE_SIZE 0 + + /** Set number of maximally-cached circle data. + * The circumference of 1/4 circle are saved for anti-aliasing. + * `radius * 4` bytes are used per circle (the most often used radiuses are saved). + * - 0: disables caching */ + #define LV_DRAW_SW_CIRCLE_CACHE_SIZE 4 + #endif + + #define LV_USE_DRAW_SW_ASM LV_DRAW_SW_ASM_NONE + + #if LV_USE_DRAW_SW_ASM == LV_DRAW_SW_ASM_CUSTOM + #define LV_DRAW_SW_ASM_CUSTOM_INCLUDE "" + #endif + + /** Enable drawing complex gradients in software: linear at an angle, radial or conical */ + #define LV_USE_DRAW_SW_COMPLEX_GRADIENTS 0 + +#endif + +/*Use TSi's aka (Think Silicon) NemaGFX */ +#define LV_USE_NEMA_GFX 0 + +#if LV_USE_NEMA_GFX + /** Select which NemaGFX static library headers to use. Possible options: + * - LV_NEMA_LIB_NONE an alias of LV_NEMA_LIB_M33_REVC + * - LV_NEMA_LIB_M33_REVC + * - LV_NEMA_LIB_M33_NEMAPVG + * - LV_NEMA_LIB_M55 + * - LV_NEMA_LIB_M7 + * You must also take care to link the correct corresponding static library + * in libs/nema_gfx/lib/core/ + */ + #define LV_USE_NEMA_LIB LV_NEMA_LIB_NONE + + /** Select which NemaGFX HAL to use. Possible options: + * - LV_NEMA_HAL_CUSTOM + * - LV_NEMA_HAL_STM32 */ + #define LV_USE_NEMA_HAL LV_NEMA_HAL_CUSTOM + #if LV_USE_NEMA_HAL == LV_NEMA_HAL_STM32 + #define LV_NEMA_STM32_HAL_INCLUDE + + /** Set it to a value like __attribute__((section("Nemagfx_Memory_Pool_Buffer"))) + * and define the section in the linker script if you need the GPU memory to + * be, e.g. in a region where accesses will not be cached. + */ + #define LV_NEMA_STM32_HAL_ATTRIBUTE_POOL_MEM + #endif + + /*Enable Vector Graphics Operations. Available only if NemaVG library is present*/ + #define LV_USE_NEMA_VG 0 + #if LV_USE_NEMA_VG + /*Define application's resolution used for VG related buffer allocation */ + #define LV_NEMA_GFX_MAX_RESX 800 + #define LV_NEMA_GFX_MAX_RESY 600 + #endif +#endif + +/** Use NXP's PXP on iMX RTxxx platforms. */ +#define LV_USE_PXP 0 + +#if LV_USE_PXP + /** Use PXP for drawing.*/ + #define LV_USE_DRAW_PXP 1 + + /** Use PXP to rotate display.*/ + #define LV_USE_ROTATE_PXP 0 + + #if LV_USE_DRAW_PXP && LV_USE_OS + /** Use additional draw thread for PXP processing.*/ + #define LV_USE_PXP_DRAW_THREAD 1 + #endif + + /** Enable PXP asserts. */ + #define LV_USE_PXP_ASSERT 0 +#endif + +/** Use NXP's G2D on MPU platforms. */ +#define LV_USE_G2D 0 + +#if LV_USE_G2D + /** Use G2D for drawing. **/ + #define LV_USE_DRAW_G2D 1 + + /** Use G2D to rotate display. **/ + #define LV_USE_ROTATE_G2D 0 + + /** Maximum number of buffers that can be stored for G2D draw unit. + * Includes the frame buffers and assets. */ + #define LV_G2D_HASH_TABLE_SIZE 50 + + #if LV_USE_DRAW_G2D && LV_USE_OS + /** Use additional draw thread for G2D processing.*/ + #define LV_USE_G2D_DRAW_THREAD 1 + #endif + + /** Enable G2D asserts. */ + #define LV_USE_G2D_ASSERT 0 +#endif + +/** Use Renesas Dave2D on RA platforms. */ +#define LV_USE_DRAW_DAVE2D 0 + +/** Draw using cached SDL textures*/ +#define LV_USE_DRAW_SDL 0 + +/** Use VG-Lite GPU. */ +#define LV_USE_DRAW_VG_LITE 0 +#if LV_USE_DRAW_VG_LITE + /** Enable VG-Lite custom external 'gpu_init()' function */ + #define LV_VG_LITE_USE_GPU_INIT 0 + + /** Enable VG-Lite assert. */ + #define LV_VG_LITE_USE_ASSERT 0 + + /** VG-Lite flush commit trigger threshold. GPU will try to batch these many draw tasks. */ + #define LV_VG_LITE_FLUSH_MAX_COUNT 8 + + /** Enable border to simulate shadow. + * NOTE: which usually improves performance, + * but does not guarantee the same rendering quality as the software. */ + #define LV_VG_LITE_USE_BOX_SHADOW 1 + + /** VG-Lite gradient maximum cache number. + * @note The memory usage of a single gradient image is 4K bytes. */ + #define LV_VG_LITE_GRAD_CACHE_CNT 32 + + /** VG-Lite stroke maximum cache number. */ + #define LV_VG_LITE_STROKE_CACHE_CNT 32 + + /** VG-Lite unaligned bitmap font maximum cache number. */ + #define LV_VG_LITE_BITMAP_FONT_CACHE_CNT 256 + + /** Remove VLC_OP_CLOSE path instruction (Workaround for NXP) **/ + #define LV_VG_LITE_DISABLE_VLC_OP_CLOSE 0 + + /** Disable blit rectangular offset to resolve certain hardware errors. */ + #define LV_VG_LITE_DISABLE_BLIT_RECT_OFFSET 0 + + /** Disable linear gradient extension for some older versions of drivers. */ + #define LV_VG_LITE_DISABLE_LINEAR_GRADIENT_EXT 0 + + /** Maximum path dump print length (in points) */ + #define LV_VG_LITE_PATH_DUMP_MAX_LEN 1000 + + /** Enable usage of the LVGL's built-in vg_lite driver */ + #define LV_USE_VG_LITE_DRIVER 0 + #if LV_USE_VG_LITE_DRIVER + /** Used to pick the correct GPU series folder valid options are gc255, gc355 and gc555*/ + #define LV_VG_LITE_HAL_GPU_SERIES gc255 + + /** Used to pick the correct GPU revision header it depends on the vendor */ + #define LV_VG_LITE_HAL_GPU_REVISION 0x40 + + /** Base memory address of the GPU IP it depends on SoC, + * default value is for NXP based devices */ + #define LV_VG_LITE_HAL_GPU_BASE_ADDRESS 0x40240000 + #endif /*LV_USE_VG_LITE_DRIVER*/ + + /** Use ThorVG (a software vector library) as VG-Lite driver to allow testing VGLite on PC + * Requires: LV_USE_THORVG_INTERNAL or LV_USE_THORVG_EXTERNAL */ + #define LV_USE_VG_LITE_THORVG 0 + #if LV_USE_VG_LITE_THORVG + /** Enable LVGL's blend mode support */ + #define LV_VG_LITE_THORVG_LVGL_BLEND_SUPPORT 0 + + /** Enable YUV color format support */ + #define LV_VG_LITE_THORVG_YUV_SUPPORT 0 + + /** Enable Linear gradient extension support */ + #define LV_VG_LITE_THORVG_LINEAR_GRADIENT_EXT_SUPPORT 0 + + /** Enable alignment on 16 pixels */ + #define LV_VG_LITE_THORVG_16PIXELS_ALIGN 1 + + /** Buffer address alignment */ + #define LV_VG_LITE_THORVG_BUF_ADDR_ALIGN 64 + + /** Enable multi-thread render */ + #define LV_VG_LITE_THORVG_THREAD_RENDER 0 + #endif /*LV_USE_VG_LITE_THORVG*/ +#endif + +/** Accelerate blends, fills, etc. with STM32 DMA2D */ +#define LV_USE_DRAW_DMA2D 0 +#if LV_USE_DRAW_DMA2D + #define LV_DRAW_DMA2D_HAL_INCLUDE "stm32h7xx_hal.h" + + /* if enabled, the user is required to call `lv_draw_dma2d_transfer_complete_interrupt_handler` + * upon receiving the DMA2D global interrupt + */ + #define LV_USE_DRAW_DMA2D_INTERRUPT 0 +#endif + +/** Draw using cached OpenGLES textures. Requires LV_USE_OPENGLES */ +#define LV_USE_DRAW_OPENGLES 0 +#if LV_USE_DRAW_OPENGLES + #define LV_DRAW_OPENGLES_TEXTURE_CACHE_COUNT 64 +#endif + +/** Draw using espressif PPA accelerator */ +#define LV_USE_PPA 0 +#if LV_USE_PPA + #define LV_USE_PPA_IMG 0 + #define LV_PPA_BURST_LENGTH 128 +#endif + +/* Use EVE FT81X GPU. */ +#define LV_USE_DRAW_EVE 0 +#if LV_USE_DRAW_EVE + /* EVE_GEN value: 2, 3, or 4 */ + #define LV_DRAW_EVE_EVE_GENERATION 4 + + /* The maximum number of bytes to buffer before a single SPI transmission. + * Set it to 0 to disable write buffering. + */ + #define LV_DRAW_EVE_WRITE_BUFFER_SIZE 2048 +#endif + +/** Use NanoVG Renderer + * - Requires LV_USE_NANOVG, LV_USE_MATRIX. + */ +#define LV_USE_DRAW_NANOVG 0 +#if LV_USE_DRAW_NANOVG + /** Select OpenGL backend for NanoVG: + * - LV_NANOVG_BACKEND_GL2: OpenGL 2.0 + * - LV_NANOVG_BACKEND_GL3: OpenGL 3.0+ + * - LV_NANOVG_BACKEND_GLES2: OpenGL ES 2.0 + * - LV_NANOVG_BACKEND_GLES3: OpenGL ES 3.0+ + */ + #define LV_NANOVG_BACKEND LV_NANOVG_BACKEND_GLES2 + + /** Draw image texture cache count. */ + #define LV_NANOVG_IMAGE_CACHE_CNT 128 + + /** Draw letter texture cache count. */ + #define LV_NANOVG_LETTER_CACHE_CNT 512 +#endif + +/*======================= + * FEATURE CONFIGURATION + *=======================*/ + +/*------------- + * Logging + *-----------*/ + +/** Enable log module */ +#define LV_USE_LOG 1 +#if LV_USE_LOG + /** Set value to one of the following levels of logging detail: + * - LV_LOG_LEVEL_TRACE Log detailed information. + * - LV_LOG_LEVEL_INFO Log important events. + * - LV_LOG_LEVEL_WARN Log if something unwanted happened but didn't cause a problem. + * - LV_LOG_LEVEL_ERROR Log only critical issues, when system may fail. + * - LV_LOG_LEVEL_USER Log only custom log messages added by the user. + * - LV_LOG_LEVEL_NONE Do not log anything. */ + #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN + + /** - 1: Print log with 'printf'; + * - 0: User needs to register a callback with `lv_log_register_print_cb()`. */ + #define LV_LOG_PRINTF 0 + + /** Set callback to print logs. + * E.g `my_print`. The prototype should be `void my_print(lv_log_level_t level, const char * buf)`. + * Can be overwritten by `lv_log_register_print_cb`. */ + //#define LV_LOG_PRINT_CB + + /** - 1: Enable printing timestamp; + * - 0: Disable printing timestamp. */ + #define LV_LOG_USE_TIMESTAMP 1 + + /** - 1: Print file and line number of the log; + * - 0: Do not print file and line number of the log. */ + #define LV_LOG_USE_FILE_LINE 1 + + /* Enable/disable LV_LOG_TRACE in modules that produces a huge number of logs. */ + #define LV_LOG_TRACE_MEM 1 /**< Enable/disable trace logs in memory operations. */ + #define LV_LOG_TRACE_TIMER 1 /**< Enable/disable trace logs in timer operations. */ + #define LV_LOG_TRACE_INDEV 1 /**< Enable/disable trace logs in input device operations. */ + #define LV_LOG_TRACE_DISP_REFR 1 /**< Enable/disable trace logs in display re-draw operations. */ + #define LV_LOG_TRACE_EVENT 1 /**< Enable/disable trace logs in event dispatch logic. */ + #define LV_LOG_TRACE_OBJ_CREATE 1 /**< Enable/disable trace logs in object creation (core `obj` creation plus every widget). */ + #define LV_LOG_TRACE_LAYOUT 1 /**< Enable/disable trace logs in flex- and grid-layout operations. */ + #define LV_LOG_TRACE_ANIM 1 /**< Enable/disable trace logs in animation logic. */ + #define LV_LOG_TRACE_CACHE 1 /**< Enable/disable trace logs in cache operations. */ +#endif /*LV_USE_LOG*/ + +/*------------- + * Asserts + *-----------*/ + +/* Enable assertion failures if an operation fails or invalid data is found. + * If LV_USE_LOG is enabled, an error message will be printed on failure. */ +#define LV_USE_ASSERT_NULL 1 /**< Check if the parameter is NULL. (Very fast, recommended) */ +#define LV_USE_ASSERT_MALLOC 1 /**< Checks is the memory is successfully allocated or no. (Very fast, recommended) */ +#define LV_USE_ASSERT_STYLE 0 /**< Check if the styles are properly initialized. (Very fast, recommended) */ +#define LV_USE_ASSERT_MEM_INTEGRITY 0 /**< Check the integrity of `lv_mem` after critical operations. (Slow) */ +#define LV_USE_ASSERT_OBJ 0 /**< Check the object's type and existence (e.g. not deleted). (Slow) */ + +/** Add a custom handler when assert happens e.g. to restart MCU. */ +#define LV_ASSERT_HANDLER_INCLUDE +#define LV_ASSERT_HANDLER while(1); /**< Halt by default */ + +/*------------- + * Debug + *-----------*/ + +/** 1: Draw random colored rectangles over the redrawn areas. */ +#define LV_USE_REFR_DEBUG 0 + +/** 1: Draw a red overlay for ARGB layers and a green overlay for RGB layers*/ +#define LV_USE_LAYER_DEBUG 0 + +/** 1: Adds the following behaviors for debugging: + * - Draw overlays with different colors for each draw_unit's tasks. + * - Draw index number of draw unit on white background. + * - For layers, draws index number of draw unit on black background. */ +#define LV_USE_PARALLEL_DRAW_DEBUG 0 + +/*------------- + * Others + *-----------*/ + +#define LV_ENABLE_GLOBAL_CUSTOM 0 +#if LV_ENABLE_GLOBAL_CUSTOM + /** Header to include for custom 'lv_global' function" */ + #define LV_GLOBAL_CUSTOM_INCLUDE +#endif + +/** Default cache size in bytes. + * Used by image decoders such as `lv_lodepng` to keep the decoded image in memory. + * If size is not set to 0, the decoder will fail to decode when the cache is full. + * If size is 0, the cache function is not enabled and the decoded memory will be + * released immediately after use. */ +#define LV_CACHE_DEF_SIZE 0 + +/** Default number of image header cache entries. The cache is used to store the headers of images + * The main logic is like `LV_CACHE_DEF_SIZE` but for image headers. */ +#define LV_IMAGE_HEADER_CACHE_DEF_CNT 0 + +/** Number of stops allowed per gradient. Increase this to allow more stops. + * This adds (sizeof(lv_color_t) + 1) bytes per additional stop. */ +#define LV_GRADIENT_MAX_STOPS 2 + +/** Adjust color mix functions rounding. GPUs might calculate color mix (blending) differently. + * - 0: round down, + * - 64: round up from x.75, + * - 128: round up from half, + * - 192: round up from x.25, + * - 254: round up */ +#define LV_COLOR_MIX_ROUND_OFS 0 + +/** Add 2 x 32-bit variables to each `lv_obj_t` to speed up getting style properties */ +#define LV_OBJ_STYLE_CACHE 0 + +/** Add `id` field to `lv_obj_t` */ +#define LV_USE_OBJ_ID 0 + +/** Enable support widget names*/ +#define LV_USE_OBJ_NAME 0 + +/** Automatically assign an ID when obj is created */ +#define LV_OBJ_ID_AUTO_ASSIGN LV_USE_OBJ_ID + +/** Use builtin obj ID handler functions: +* - lv_obj_assign_id: Called when a widget is created. Use a separate counter for each widget class as an ID. +* - lv_obj_id_compare: Compare the ID to decide if it matches with a requested value. +* - lv_obj_stringify_id: Return string-ified identifier, e.g. "button3". +* - lv_obj_free_id: Does nothing, as there is no memory allocation for the ID. +* When disabled these functions needs to be implemented by the user.*/ +#define LV_USE_OBJ_ID_BUILTIN 1 + +/** Use obj property set/get API. */ +#define LV_USE_OBJ_PROPERTY 0 + +/** Enable property name support. */ +#define LV_USE_OBJ_PROPERTY_NAME 1 + +/* Enable the multi-touch gesture recognition feature */ +/* Gesture recognition requires the use of floats */ +#define LV_USE_GESTURE_RECOGNITION 0 + +/*===================== + * COMPILER SETTINGS + *====================*/ + +/** For big endian systems set to 1 */ +#define LV_BIG_ENDIAN_SYSTEM 0 + +/** Define a custom attribute for `lv_tick_inc` function */ +#define LV_ATTRIBUTE_TICK_INC + +/** Define a custom attribute for `lv_timer_handler` function */ +#define LV_ATTRIBUTE_TIMER_HANDLER + +/** Define a custom attribute for `lv_display_flush_ready` function */ +#define LV_ATTRIBUTE_FLUSH_READY + +/** Align VG_LITE buffers on this number of bytes. + * @note vglite_src_buf_aligned() uses this value to validate alignment of passed buffer pointers. */ +#define LV_ATTRIBUTE_MEM_ALIGN_SIZE 1 + +/** Will be added where memory needs to be aligned (with -Os data might not be aligned to boundary by default). + * E.g. __attribute__((aligned(4)))*/ +#define LV_ATTRIBUTE_MEM_ALIGN + +/** Attribute to mark large constant arrays, for example for font bitmaps */ +#define LV_ATTRIBUTE_LARGE_CONST + +/** Compiler prefix for a large array declaration in RAM */ +#define LV_ATTRIBUTE_LARGE_RAM_ARRAY + +/** Place performance critical functions into a faster memory (e.g RAM) */ +#define LV_ATTRIBUTE_FAST_MEM + +/** Export integer constant to binding. This macro is used with constants in the form of LV_ that + * should also appear on LVGL binding API such as MicroPython. */ +#define LV_EXPORT_CONST_INT(int_value) struct _silence_gcc_warning /**< The default value just prevents GCC warning */ + +/** Prefix all global extern data with this */ +#define LV_ATTRIBUTE_EXTERN_DATA + +/** Use `float` as `lv_value_precise_t` */ +#define LV_USE_FLOAT 0 + +/** Enable matrix support + * - Requires `LV_USE_FLOAT = 1` */ +#define LV_USE_MATRIX 0 + +/** Include `lvgl_private.h` in `lvgl.h` to access internal data and functions by default */ +#ifndef LV_USE_PRIVATE_API + #define LV_USE_PRIVATE_API 0 +#endif + +/*================== + * FONT USAGE + *===================*/ + +/* Montserrat fonts with ASCII range and some symbols using bpp = 4 + * https://fonts.google.com/specimen/Montserrat */ +#define LV_FONT_MONTSERRAT_8 0 +#define LV_FONT_MONTSERRAT_10 0 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 0 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_22 0 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_26 0 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_MONTSERRAT_30 0 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_34 0 +#define LV_FONT_MONTSERRAT_36 1 +#define LV_FONT_MONTSERRAT_38 0 +#define LV_FONT_MONTSERRAT_40 1 +#define LV_FONT_MONTSERRAT_42 0 +#define LV_FONT_MONTSERRAT_44 0 +#define LV_FONT_MONTSERRAT_46 0 +#define LV_FONT_MONTSERRAT_48 1 + +/* Demonstrate special features */ +#define LV_FONT_MONTSERRAT_28_COMPRESSED 0 /**< bpp = 3 */ +#define LV_FONT_DEJAVU_16_PERSIAN_HEBREW 0 /**< Hebrew, Arabic, Persian letters and all their forms */ +#define LV_FONT_SOURCE_HAN_SANS_SC_14_CJK 0 /**< 1338 most common CJK radicals */ +#define LV_FONT_SOURCE_HAN_SANS_SC_16_CJK 0 /**< 1338 most common CJK radicals */ + +/** Pixel perfect monospaced fonts */ +#define LV_FONT_UNSCII_8 0 +#define LV_FONT_UNSCII_16 0 + +/** Optionally declare custom fonts here. + * + * You can use any of these fonts as the default font too and they will be available + * globally. Example: + * + * @code + * #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(my_font_1) LV_FONT_DECLARE(my_font_2) + * @endcode + */ +#define LV_FONT_CUSTOM_DECLARE + +/** Always set a default font */ +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +/** Enable handling large font and/or fonts with a lot of characters. + * The limit depends on the font size, font face and bpp. + * A compiler error will be triggered if a font needs it. */ +#define LV_FONT_FMT_TXT_LARGE 0 + +/** Enables/disables support for compressed fonts. */ +#define LV_USE_FONT_COMPRESSED 0 + +/** Enable drawing placeholders when glyph dsc is not found. */ +#define LV_USE_FONT_PLACEHOLDER 1 + +/*================= + * TEXT SETTINGS + *=================*/ + +/** + * Select a character encoding for strings. + * Your IDE or editor should have the same character encoding. + * - LV_TXT_ENC_UTF8 + * - LV_TXT_ENC_ASCII + */ +#define LV_TXT_ENC LV_TXT_ENC_UTF8 + +/** While rendering text strings, break (wrap) text on these chars. */ +#define LV_TXT_BREAK_CHARS " ,.;:-_)]}" + +/** If a word is at least this long, will break wherever "prettiest". + * To disable, set to a value <= 0. */ +#define LV_TXT_LINE_BREAK_LONG_LEN 0 + +/** Minimum number of characters in a long word to put on a line before a break. + * Depends on LV_TXT_LINE_BREAK_LONG_LEN. */ +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 + +/** Minimum number of characters in a long word to put on a line after a break. + * Depends on LV_TXT_LINE_BREAK_LONG_LEN. */ +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 + +/** Support bidirectional text. Allows mixing Left-to-Right and Right-to-Left text. + * The direction will be processed according to the Unicode Bidirectional Algorithm: + * https://www.w3.org/International/articles/inline-bidi-markup/uba-basics */ +#define LV_USE_BIDI 0 +#if LV_USE_BIDI + /*Set the default direction. Supported values: + *`LV_BASE_DIR_LTR` Left-to-Right + *`LV_BASE_DIR_RTL` Right-to-Left + *`LV_BASE_DIR_AUTO` detect text base direction*/ + #define LV_BIDI_BASE_DIR_DEF LV_BASE_DIR_AUTO +#endif + +/** Enable Arabic/Persian processing + * In these languages characters should be replaced with another form based on their position in the text */ +#define LV_USE_ARABIC_PERSIAN_CHARS 0 + +/*The control character to use for signaling text recoloring*/ +#define LV_TXT_COLOR_CMD "#" + +/*================== + * WIDGETS + *================*/ +/* Documentation for widgets can be found here: https://docs.lvgl.io/master/widgets/index.html . */ + +/** 1: Causes these widgets to be given default values at creation time. + * - lv_buttonmatrix_t: Get default maps: {"Btn1", "Btn2", "Btn3", "\n", "Btn4", "Btn5", ""}, else map not set. + * - lv_checkbox_t : String label set to "Check box", else set to empty string. + * - lv_dropdown_t : Options set to "Option 1", "Option 2", "Option 3", else no values are set. + * - lv_roller_t : Options set to "Option 1", "Option 2", "Option 3", "Option 4", "Option 5", else no values are set. + * - lv_label_t : Text set to "Text", else empty string. + * - lv_arclabel_t : Text set to "Arced Text", else empty string. + * */ +#define LV_WIDGETS_HAS_DEFAULT_VALUE 1 + +#define LV_USE_ANIMIMG 1 + +#define LV_USE_ARC 1 + +#define LV_USE_ARCLABEL 1 + +#define LV_USE_BAR 1 + +#define LV_USE_BUTTON 1 + +#define LV_USE_BUTTONMATRIX 1 + +#define LV_USE_CALENDAR 1 +#if LV_USE_CALENDAR + #define LV_CALENDAR_WEEK_STARTS_MONDAY 0 + #if LV_CALENDAR_WEEK_STARTS_MONDAY + #define LV_CALENDAR_DEFAULT_DAY_NAMES {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + #else + #define LV_CALENDAR_DEFAULT_DAY_NAMES {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + #endif + + #define LV_CALENDAR_DEFAULT_MONTH_NAMES {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} + #define LV_USE_CALENDAR_HEADER_ARROW 1 + #define LV_USE_CALENDAR_HEADER_DROPDOWN 1 + #define LV_USE_CALENDAR_CHINESE 0 +#endif /*LV_USE_CALENDAR*/ + +#define LV_USE_CANVAS 1 + +#define LV_USE_CHART 1 + +#define LV_USE_CHECKBOX 1 + +#define LV_USE_DROPDOWN 1 /**< Requires: lv_label */ + +#define LV_USE_IMAGE 1 /**< Requires: lv_label */ + +#define LV_USE_IMAGEBUTTON 1 + +#define LV_USE_KEYBOARD 1 + +#define LV_USE_LABEL 1 +#if LV_USE_LABEL + #define LV_LABEL_TEXT_SELECTION 1 /**< Enable selecting text of the label */ + #define LV_LABEL_LONG_TXT_HINT 1 /**< Store some extra info in labels to speed up drawing of very long text */ + #define LV_LABEL_WAIT_CHAR_COUNT 3 /**< The count of wait chart */ +#endif + +#define LV_USE_LED 1 + +#define LV_USE_LINE 1 + +#define LV_USE_LIST 1 + +#define LV_USE_LOTTIE 0 /**< Requires: lv_canvas, thorvg */ + +#define LV_USE_MENU 1 + +#define LV_USE_MSGBOX 1 + +#define LV_USE_ROLLER 1 /**< Requires: lv_label */ + +#define LV_USE_SCALE 1 + +#define LV_USE_SLIDER 1 /**< Requires: lv_bar */ + +#define LV_USE_SPAN 1 +#if LV_USE_SPAN + /** A line of text can contain this maximum number of span descriptors. */ + #define LV_SPAN_SNIPPET_STACK_SIZE 64 +#endif + +#define LV_USE_SPINBOX 1 + +#define LV_USE_SPINNER 1 + +#define LV_USE_SWITCH 1 + +#define LV_USE_TABLE 1 + +#define LV_USE_TABVIEW 1 + +#define LV_USE_TEXTAREA 1 /**< Requires: lv_label */ +#if LV_USE_TEXTAREA != 0 + #define LV_TEXTAREA_DEF_PWD_SHOW_TIME 1500 /**< [ms] */ +#endif + +#define LV_USE_TILEVIEW 1 + +#define LV_USE_WIN 1 + +#define LV_USE_3DTEXTURE 0 + +/*================== + * THEMES + *==================*/ +/* Documentation for themes can be found here: https://docs.lvgl.io/master/common-widget-features/styles/styles.html#themes . */ + +/** A simple, impressive and very complete theme */ +#define LV_USE_THEME_DEFAULT 1 +#if LV_USE_THEME_DEFAULT + /** 0: Light mode; 1: Dark mode */ + #define LV_THEME_DEFAULT_DARK 0 + + /** 1: Enable grow on press */ + #define LV_THEME_DEFAULT_GROW 1 + + /** Default transition time in ms. */ + #define LV_THEME_DEFAULT_TRANSITION_TIME 80 +#endif /*LV_USE_THEME_DEFAULT*/ + +/** A very simple theme that is a good starting point for a custom theme */ +#define LV_USE_THEME_SIMPLE 1 + +/** A theme designed for monochrome displays */ +#define LV_USE_THEME_MONO 1 + +/*================== + * LAYOUTS + *==================*/ +/* Documentation for layouts can be found here: https://docs.lvgl.io/master/common-widget-features/layouts/index.html . */ + +/** A layout similar to Flexbox in CSS. */ +#define LV_USE_FLEX 1 + +/** A layout similar to Grid in CSS. */ +#define LV_USE_GRID 1 + +/*==================== + * 3RD PARTS LIBRARIES + *====================*/ +/* Documentation for libraries can be found here: https://docs.lvgl.io/master/libs/index.html . */ + +/* File system interfaces for common APIs */ + +/** Setting a default driver letter allows skipping the driver prefix in filepaths. + * Documentation about how to use the below driver-identifier letters can be found at + * https://docs.lvgl.io/master/main-modules/fs.html#lv-fs-identifier-letters . */ +#define LV_FS_DEFAULT_DRIVER_LETTER '\0' + +/** API for fopen, fread, etc. */ +#define LV_USE_FS_STDIO 0 +#if LV_USE_FS_STDIO + #define LV_FS_STDIO_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_STDIO_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ + #define LV_FS_STDIO_CACHE_SIZE 0 /**< >0 to cache this number of bytes in lv_fs_read() */ +#endif + +/** API for open, read, etc. */ +#define LV_USE_FS_POSIX 0 +#if LV_USE_FS_POSIX + #define LV_FS_POSIX_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_POSIX_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ + #define LV_FS_POSIX_CACHE_SIZE 0 /**< >0 to cache this number of bytes in lv_fs_read() */ +#endif + +/** API for CreateFile, ReadFile, etc. */ +#define LV_USE_FS_WIN32 0 +#if LV_USE_FS_WIN32 + #define LV_FS_WIN32_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_WIN32_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ + #define LV_FS_WIN32_CACHE_SIZE 0 /**< >0 to cache this number of bytes in lv_fs_read() */ +#endif + +/** API for FATFS (needs to be added separately). Uses f_open, f_read, etc. */ +#define LV_USE_FS_FATFS 0 +#if LV_USE_FS_FATFS + #define LV_FS_FATFS_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_FATFS_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ + #define LV_FS_FATFS_CACHE_SIZE 0 /**< >0 to cache this number of bytes in lv_fs_read() */ +#endif + +/** API for memory-mapped file access. */ +#define LV_USE_FS_MEMFS 0 +#if LV_USE_FS_MEMFS + #define LV_FS_MEMFS_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ +#endif + +/** API for LittleFs. */ +#define LV_USE_FS_LITTLEFS 0 +#if LV_USE_FS_LITTLEFS + #define LV_FS_LITTLEFS_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_LITTLEFS_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ +#endif + +/** API for Arduino LittleFs. */ +#define LV_USE_FS_ARDUINO_ESP_LITTLEFS 0 +#if LV_USE_FS_ARDUINO_ESP_LITTLEFS + #define LV_FS_ARDUINO_ESP_LITTLEFS_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_ARDUINO_ESP_LITTLEFS_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ +#endif + +/** API for Arduino Sd. */ +#define LV_USE_FS_ARDUINO_SD 0 +#if LV_USE_FS_ARDUINO_SD + #define LV_FS_ARDUINO_SD_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ + #define LV_FS_ARDUINO_SD_PATH "" /**< Set the working directory. File/directory paths will be appended to it. */ +#endif + +/** API for UEFI */ +#define LV_USE_FS_UEFI 0 +#if LV_USE_FS_UEFI + #define LV_FS_UEFI_LETTER '\0' /**< Set an upper-case driver-identifier letter for this driver (e.g. 'A'). */ +#endif + +#define LV_USE_FS_FROGFS 0 +#if LV_USE_FS_FROGFS + #define LV_FS_FROGFS_LETTER '\0' +#endif + +/** LODEPNG decoder library */ +#define LV_USE_LODEPNG 1 + +/** PNG decoder(libpng) library */ +#define LV_USE_LIBPNG 0 + +/** BMP decoder library */ +#define LV_USE_BMP 0 + +/** JPG + split JPG decoder library. + * Split JPG is a custom format optimized for embedded systems. */ +#define LV_USE_TJPGD 0 + +/** libjpeg-turbo decoder library. + * - Supports complete JPEG specifications and high-performance JPEG decoding. */ +#define LV_USE_LIBJPEG_TURBO 0 + +/** WebP decoder library */ +#define LV_USE_LIBWEBP 0 + +/** GIF decoder library */ +#define LV_USE_GIF 0 +#if LV_USE_GIF + /** GIF decoder accelerate */ + #define LV_GIF_CACHE_DECODE_DATA 0 +#endif + +/** GStreamer library */ +#define LV_USE_GSTREAMER 0 + +/** Decode bin images to RAM */ +#define LV_BIN_DECODER_RAM_LOAD 0 + +/** RLE decompress library */ +#define LV_USE_RLE 0 + +/** QR code library */ +#define LV_USE_QRCODE 0 + +/** Barcode code library */ +#define LV_USE_BARCODE 0 + +/** FreeType library */ +#define LV_USE_FREETYPE 0 +#if LV_USE_FREETYPE + /** Let FreeType use LVGL memory and file porting */ + #define LV_FREETYPE_USE_LVGL_PORT 0 + + /** Cache count of glyphs in FreeType, i.e. number of glyphs that can be cached. + * The higher the value, the more memory will be used. */ + #define LV_FREETYPE_CACHE_FT_GLYPH_CNT 256 +#endif + +/** Built-in TTF decoder */ +#define LV_USE_TINY_TTF 0 +#if LV_USE_TINY_TTF + /* Enable loading TTF data from files */ + #define LV_TINY_TTF_FILE_SUPPORT 0 + #define LV_TINY_TTF_CACHE_GLYPH_CNT 128 + #define LV_TINY_TTF_CACHE_KERNING_CNT 256 +#endif + +/** Rlottie library */ +#define LV_USE_RLOTTIE 0 + +/** Requires `LV_USE_3DTEXTURE = 1` */ +#define LV_USE_GLTF 0 + +/** Enable Vector Graphic APIs + * Requires `LV_USE_MATRIX = 1` + * and a rendering engine supporting vector graphics, e.g. + * (LV_USE_DRAW_SW and LV_USE_THORVG) or LV_USE_DRAW_VG_LITE or LV_USE_NEMA_VG. */ +#define LV_USE_VECTOR_GRAPHIC 0 + +/** Enable ThorVG (vector graphics library) from the src/libs folder. + * Requires LV_USE_VECTOR_GRAPHIC */ +#define LV_USE_THORVG_INTERNAL 0 + +/** Enable ThorVG by assuming that its installed and linked to the project + * Requires LV_USE_VECTOR_GRAPHIC */ +#define LV_USE_THORVG_EXTERNAL 0 + +/** Enable NanoVG (vector graphics library) */ +#define LV_USE_NANOVG 0 + +/** Use lvgl built-in LZ4 lib */ +#define LV_USE_LZ4_INTERNAL 0 + +/** Use external LZ4 library */ +#define LV_USE_LZ4_EXTERNAL 0 + +/*SVG library + * - Requires `LV_USE_VECTOR_GRAPHIC = 1` */ +#define LV_USE_SVG 0 +#define LV_USE_SVG_ANIMATION 0 +#define LV_USE_SVG_DEBUG 0 + +/** FFmpeg library for image decoding and playing videos. + * Supports all major image formats so do not enable other image decoder with it. */ +#define LV_USE_FFMPEG 0 +#if LV_USE_FFMPEG + /** Dump input information to stderr */ + #define LV_FFMPEG_DUMP_FORMAT 0 + /** Use lvgl file path in FFmpeg Player widget + * You won't be able to open URLs after enabling this feature. + * Note that FFmpeg image decoder will always use lvgl file system. */ + #define LV_FFMPEG_PLAYER_USE_LV_FS 0 +#endif + +/*================== + * OTHERS + *==================*/ +/* Documentation for several of the below items can be found here: https://docs.lvgl.io/master/auxiliary-modules/index.html . */ + +/** 1: Enable API to take snapshot for object */ +#define LV_USE_SNAPSHOT 0 + +/** 1: Enable system monitor component */ +#define LV_USE_SYSMON 0 +#if LV_USE_SYSMON + /** Get the idle percentage. E.g. uint32_t my_get_idle(void); */ + #define LV_SYSMON_GET_IDLE lv_os_get_idle_percent + /** 1: Enable usage of lv_os_get_proc_idle_percent.*/ + #define LV_SYSMON_PROC_IDLE_AVAILABLE 0 + #if LV_SYSMON_PROC_IDLE_AVAILABLE + /** Get the applications idle percentage. + * - Requires `LV_USE_OS == LV_OS_PTHREAD` */ + #define LV_SYSMON_GET_PROC_IDLE lv_os_get_proc_idle_percent + #endif + + /** 1: Show CPU usage and FPS count. + * - Requires `LV_USE_SYSMON = 1` */ + #define LV_USE_PERF_MONITOR 0 + #if LV_USE_PERF_MONITOR + #define LV_USE_PERF_MONITOR_POS LV_ALIGN_BOTTOM_RIGHT + + /** 0: Displays performance data on the screen; 1: Prints performance data using log. */ + #define LV_USE_PERF_MONITOR_LOG_MODE 0 + #endif + + /** 1: Show used memory and memory fragmentation. + * - Requires `LV_USE_STDLIB_MALLOC = LV_STDLIB_BUILTIN` + * - Requires `LV_USE_SYSMON = 1`*/ + #define LV_USE_MEM_MONITOR 0 + #if LV_USE_MEM_MONITOR + #define LV_USE_MEM_MONITOR_POS LV_ALIGN_BOTTOM_LEFT + #endif +#endif /*LV_USE_SYSMON*/ + +/** 1: Enable runtime performance profiler */ +#define LV_USE_PROFILER 0 +#if LV_USE_PROFILER + /** 1: Enable the built-in profiler */ + #define LV_USE_PROFILER_BUILTIN 1 + #if LV_USE_PROFILER_BUILTIN + /** Default profiler trace buffer size */ + #define LV_PROFILER_BUILTIN_BUF_SIZE (16 * 1024) /**< [bytes] */ + #define LV_PROFILER_BUILTIN_DEFAULT_ENABLE 1 + #define LV_USE_PROFILER_BUILTIN_POSIX 0 /**< Enable POSIX profiler port */ + #endif + + /** Header to include for profiler */ + #define LV_PROFILER_INCLUDE "lvgl/src/misc/lv_profiler_builtin.h" + + /** Profiler start point function */ + #define LV_PROFILER_BEGIN LV_PROFILER_BUILTIN_BEGIN + + /** Profiler end point function */ + #define LV_PROFILER_END LV_PROFILER_BUILTIN_END + + /** Profiler start point function with custom tag */ + #define LV_PROFILER_BEGIN_TAG LV_PROFILER_BUILTIN_BEGIN_TAG + + /** Profiler end point function with custom tag */ + #define LV_PROFILER_END_TAG LV_PROFILER_BUILTIN_END_TAG + + /*Enable layout profiler*/ + #define LV_PROFILER_LAYOUT 1 + + /*Enable disp refr profiler*/ + #define LV_PROFILER_REFR 1 + + /*Enable draw profiler*/ + #define LV_PROFILER_DRAW 1 + + /*Enable indev profiler*/ + #define LV_PROFILER_INDEV 1 + + /*Enable decoder profiler*/ + #define LV_PROFILER_DECODER 1 + + /*Enable font profiler*/ + #define LV_PROFILER_FONT 1 + + /*Enable fs profiler*/ + #define LV_PROFILER_FS 1 + + /*Enable style profiler*/ + #define LV_PROFILER_STYLE 0 + + /*Enable timer profiler*/ + #define LV_PROFILER_TIMER 1 + + /*Enable cache profiler*/ + #define LV_PROFILER_CACHE 1 + + /*Enable event profiler*/ + #define LV_PROFILER_EVENT 1 +#endif + +/** 1: Enable Monkey test */ +#define LV_USE_MONKEY 0 + +/** 1: Enable grid navigation */ +#define LV_USE_GRIDNAV 0 + +/** 1: Enable `lv_obj` fragment logic */ +#define LV_USE_FRAGMENT 0 + +/** 1: Support using images as font in label or span widgets */ +#define LV_USE_IMGFONT 0 + +/** 1: Enable an observer pattern implementation */ +#define LV_USE_OBSERVER 1 + +/** 1: Enable Pinyin input method + * - Requires: lv_keyboard */ +#define LV_USE_IME_PINYIN 0 +#if LV_USE_IME_PINYIN + /** 1: Use default thesaurus. + * @note If you do not use the default thesaurus, be sure to use `lv_ime_pinyin` after setting the thesaurus. */ + #define LV_IME_PINYIN_USE_DEFAULT_DICT 1 + /** Set maximum number of candidate panels that can be displayed. + * @note This needs to be adjusted according to size of screen. */ + #define LV_IME_PINYIN_CAND_TEXT_NUM 6 + + /** Use 9-key input (k9). */ + #define LV_IME_PINYIN_USE_K9_MODE 1 + #if LV_IME_PINYIN_USE_K9_MODE == 1 + #define LV_IME_PINYIN_K9_CAND_TEXT_NUM 3 + #endif /*LV_IME_PINYIN_USE_K9_MODE*/ +#endif + +/** 1: Enable file explorer. + * - Requires: lv_table */ +#define LV_USE_FILE_EXPLORER 0 +#if LV_USE_FILE_EXPLORER + /** Maximum length of path */ + #define LV_FILE_EXPLORER_PATH_MAX_LEN (128) + /** Quick access bar, 1:use, 0:do not use. + * - Requires: lv_list */ + #define LV_FILE_EXPLORER_QUICK_ACCESS 1 +#endif + +/** 1: Enable Font manager */ +#define LV_USE_FONT_MANAGER 0 +#if LV_USE_FONT_MANAGER + +/**Font manager name max length*/ +#define LV_FONT_MANAGER_NAME_MAX_LEN 32 + +#endif + +/** Enable emulated input devices, time emulation, and screenshot compares. */ +#define LV_USE_TEST 0 +#if LV_USE_TEST + +/** Enable `lv_test_screenshot_compare`. + * Requires lodepng and a few MB of extra RAM. */ +#define LV_USE_TEST_SCREENSHOT_COMPARE 0 + +#if LV_USE_TEST_SCREENSHOT_COMPARE + /** 1: Automatically create missing reference images*/ + #define LV_TEST_SCREENSHOT_CREATE_REFERENCE_IMAGE 1 +#endif /*LV_USE_TEST_SCREENSHOT_COMPARE*/ + +#endif /*LV_USE_TEST*/ + +/** 1: Enable text translation support */ +#define LV_USE_TRANSLATION 0 + +/*1: Enable color filter style*/ +#define LV_USE_COLOR_FILTER 0 + +/*================== + * DEVICES + *==================*/ + +/** Use SDL to open window on PC and handle mouse and keyboard. */ +#define LV_USE_SDL 1 +#if LV_USE_SDL + #define LV_SDL_INCLUDE_PATH + #define LV_SDL_RENDER_MODE LV_DISPLAY_RENDER_MODE_DIRECT /**< LV_DISPLAY_RENDER_MODE_DIRECT is recommended for best performance */ + #define LV_SDL_BUF_COUNT 1 /**< 1 or 2 */ + #define LV_SDL_ACCELERATED 1 /**< 1: Use hardware acceleration*/ + #define LV_SDL_FULLSCREEN 0 /**< 1: Make the window full screen by default */ + #define LV_SDL_DIRECT_EXIT 1 /**< 1: Exit the application when all SDL windows are closed */ + #define LV_SDL_MOUSEWHEEL_MODE LV_SDL_MOUSEWHEEL_MODE_ENCODER /*LV_SDL_MOUSEWHEEL_MODE_ENCODER/CROWN*/ +#endif + +/** Use X11 to open window on Linux desktop and handle mouse and keyboard */ +#define LV_USE_X11 0 +#if LV_USE_X11 + #define LV_X11_DIRECT_EXIT 1 /**< Exit application when all X11 windows have been closed */ + #define LV_X11_DOUBLE_BUFFER 1 /**< Use double buffers for rendering */ + /* Select only 1 of the following render modes (LV_X11_RENDER_MODE_PARTIAL preferred!). */ + #define LV_X11_RENDER_MODE_PARTIAL 1 /**< Partial render mode (preferred) */ + #define LV_X11_RENDER_MODE_DIRECT 0 /**< Direct render mode */ + #define LV_X11_RENDER_MODE_FULL 0 /**< Full render mode */ +#endif + +/** Use Wayland to open a window and handle input on Linux or BSD desktops */ +#define LV_USE_WAYLAND 0 +#if LV_USE_WAYLAND + #define LV_WAYLAND_DIRECT_EXIT 1 /**< 1: Exit the application when all Wayland windows are closed */ +#endif + +/** Driver for /dev/fb */ +#define LV_USE_LINUX_FBDEV 0 +#if LV_USE_LINUX_FBDEV + #define LV_LINUX_FBDEV_BSD 0 + #define LV_LINUX_FBDEV_RENDER_MODE LV_DISPLAY_RENDER_MODE_PARTIAL + #define LV_LINUX_FBDEV_BUFFER_COUNT 0 + #define LV_LINUX_FBDEV_BUFFER_SIZE 60 + #define LV_LINUX_FBDEV_MMAP 1 +#endif + +/** Use Nuttx to open window and handle touchscreen */ +#define LV_USE_NUTTX 0 + +#if LV_USE_NUTTX + #define LV_USE_NUTTX_INDEPENDENT_IMAGE_HEAP 0 + + /** Use independent image heap for default draw buffer */ + #define LV_NUTTX_DEFAULT_DRAW_BUF_USE_INDEPENDENT_IMAGE_HEAP 0 + + #define LV_USE_NUTTX_LIBUV 0 + + /** Use Nuttx custom init API to open window and handle touchscreen */ + #define LV_USE_NUTTX_CUSTOM_INIT 0 + + /** Driver for /dev/lcd */ + #define LV_USE_NUTTX_LCD 0 + #if LV_USE_NUTTX_LCD + #define LV_NUTTX_LCD_BUFFER_COUNT 0 + #define LV_NUTTX_LCD_BUFFER_SIZE 60 + #endif + + /** Driver for /dev/input */ + #define LV_USE_NUTTX_TOUCHSCREEN 0 + + /** Touchscreen cursor size in pixels(<=0: disable cursor) */ + #define LV_NUTTX_TOUCHSCREEN_CURSOR_SIZE 0 + + /** Driver for /dev/mouse */ + #define LV_USE_NUTTX_MOUSE 0 + + /** Mouse movement step (pixels) */ + #define LV_USE_NUTTX_MOUSE_MOVE_STEP 1 + + /*NuttX trace file and its path*/ + #define LV_USE_NUTTX_TRACE_FILE 0 + #if LV_USE_NUTTX_TRACE_FILE + #define LV_NUTTX_TRACE_FILE_PATH "/data/lvgl-trace.log" + #endif + +#endif + +/** Driver for /dev/dri/card */ +#define LV_USE_LINUX_DRM 0 + +#if LV_USE_LINUX_DRM + + /* Use the MESA GBM library to allocate DMA buffers that can be + * shared across sub-systems and libraries using the Linux DMA-BUF API. + * The GBM library aims to provide a platform independent memory management system + * it supports the major GPU vendors - This option requires linking with libgbm */ + #define LV_USE_LINUX_DRM_GBM_BUFFERS 0 +#endif + +/** Interface for TFT_eSPI */ +#define LV_USE_TFT_ESPI 0 + +/** Interface for Lovyan_GFX */ +#define LV_USE_LOVYAN_GFX 0 + +#if LV_USE_LOVYAN_GFX + #define LV_LGFX_USER_INCLUDE "lv_lgfx_user.hpp" + +#endif /*LV_USE_LOVYAN_GFX*/ + +/** Driver for evdev input devices */ +#define LV_USE_EVDEV 0 + +/** Driver for libinput input devices */ +#define LV_USE_LIBINPUT 0 + +#if LV_USE_LIBINPUT + #define LV_LIBINPUT_BSD 0 + + /** Full keyboard support */ + #define LV_LIBINPUT_XKB 0 + #if LV_LIBINPUT_XKB + /** "setxkbmap -query" can help find the right values for your keyboard */ + #define LV_LIBINPUT_XKB_KEY_MAP { .rules = NULL, .model = "pc101", .layout = "us", .variant = NULL, .options = NULL } + #endif +#endif + +/* Drivers for LCD devices connected via SPI/parallel port */ +#define LV_USE_ST7735 0 +#define LV_USE_ST7789 0 +#define LV_USE_ST7796 0 +#define LV_USE_ILI9341 0 +#define LV_USE_FT81X 0 +#define LV_USE_NV3007 0 + +#if (LV_USE_ST7735 | LV_USE_ST7789 | LV_USE_ST7796 | LV_USE_ILI9341 | LV_USE_NV3007) + #define LV_USE_GENERIC_MIPI 1 +#else + #define LV_USE_GENERIC_MIPI 0 +#endif + +/** Driver for Renesas GLCD */ +#define LV_USE_RENESAS_GLCDC 0 + +/** Driver for ST LTDC */ +#define LV_USE_ST_LTDC 0 +#if LV_USE_ST_LTDC + /* Only used for partial. */ + #define LV_ST_LTDC_USE_DMA2D_FLUSH 0 +#endif + +/** Driver for NXP ELCDIF */ +#define LV_USE_NXP_ELCDIF 0 + +/** LVGL Windows backend */ +#define LV_USE_WINDOWS 0 + +/** LVGL UEFI backend */ +#define LV_USE_UEFI 0 +#if LV_USE_UEFI + #define LV_USE_UEFI_INCLUDE "myefi.h" /**< Header that hides the actual framework (EDK2, gnu-efi, ...) */ + #define LV_UEFI_USE_MEMORY_SERVICES 0 /**< Use the memory functions from the boot services table */ +#endif + +/** Use a generic OpenGL driver that can be used to embed in other applications or used with GLFW/EGL + * - Requires LV_USE_MATRIX. + */ +#define LV_USE_OPENGLES 0 +#if LV_USE_OPENGLES + #define LV_USE_OPENGLES_DEBUG 1 /**< Enable or disable debug for opengles */ +#endif + +/** Use GLFW to open window on PC and handle mouse and keyboard. Requires*/ +#define LV_USE_GLFW 0 + + +/** QNX Screen display and input drivers */ +#define LV_USE_QNX 0 +#if LV_USE_QNX + #define LV_QNX_BUF_COUNT 1 /**< 1 or 2 */ +#endif + +/** Enable or disable for external data and destructor function */ +#define LV_USE_EXT_DATA 0 + +/*===================== +* BUILD OPTIONS +*======================*/ + +/** Enable examples to be built with the library. */ +#define LV_BUILD_EXAMPLES 1 + +/** Build the demos */ +#define LV_BUILD_DEMOS 1 + +/*=================== + * DEMO USAGE + ====================*/ + +#if LV_BUILD_DEMOS + /** Show some widgets. This might be required to increase `LV_MEM_SIZE`. */ + #define LV_USE_DEMO_WIDGETS 0 + + /** Demonstrate usage of encoder and keyboard. */ + #define LV_USE_DEMO_KEYPAD_AND_ENCODER 0 + + /** Benchmark your system */ + #define LV_USE_DEMO_BENCHMARK 0 + + #if LV_USE_DEMO_BENCHMARK + /** Use fonts where bitmaps are aligned 16 byte and has Nx16 byte stride */ + #define LV_DEMO_BENCHMARK_ALIGNED_FONTS 0 + #endif + + /** Render test for each primitive. + * - Requires at least 480x272 display. */ + #define LV_USE_DEMO_RENDER 0 + + /** Stress test for LVGL */ + #define LV_USE_DEMO_STRESS 0 + + /** Music player demo */ + #define LV_USE_DEMO_MUSIC 0 + #if LV_USE_DEMO_MUSIC + #define LV_DEMO_MUSIC_SQUARE 0 + #define LV_DEMO_MUSIC_LANDSCAPE 0 + #define LV_DEMO_MUSIC_ROUND 0 + #define LV_DEMO_MUSIC_LARGE 0 + #define LV_DEMO_MUSIC_AUTO_PLAY 0 + #endif + + /** Vector graphic demo */ + #define LV_USE_DEMO_VECTOR_GRAPHIC 0 + + /** GLTF demo */ + #define LV_USE_DEMO_GLTF 0 + + /*--------------------------- + * Demos from lvgl/lv_demos + ---------------------------*/ + + /** Flex layout demo */ + #define LV_USE_DEMO_FLEX_LAYOUT 0 + + /** Smart-phone like multi-language demo */ + #define LV_USE_DEMO_MULTILANG 0 + + /*E-bike demo with Lottie animations (if LV_USE_LOTTIE is enabled)*/ + #define LV_USE_DEMO_EBIKE 0 + #if LV_USE_DEMO_EBIKE + #define LV_DEMO_EBIKE_PORTRAIT 0 /*0: for 480x270..480x320, 1: for 480x800..720x1280*/ + #endif + + /** High-resolution demo */ + #define LV_USE_DEMO_HIGH_RES 0 + + /* Smart watch demo */ + #define LV_USE_DEMO_SMARTWATCH 0 +#endif /* LV_BUILD_DEMOS */ + +/*--END OF LV_CONF_H--*/ + +#endif /*LV_CONF_H*/ + +#endif /*End of "Content enable"*/ diff --git a/sim/lvgl_port_sim.c b/sim/lvgl_port_sim.c new file mode 100644 index 0000000..9a69a6f --- /dev/null +++ b/sim/lvgl_port_sim.c @@ -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 +#include +#include + +#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); + } +} diff --git a/sim/sim_main.c b/sim/sim_main.c new file mode 100644 index 0000000..a307361 --- /dev/null +++ b/sim/sim_main.c @@ -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 +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/sim/snapshots/evening.json b/sim/snapshots/evening.json new file mode 100644 index 0000000..fa66075 --- /dev/null +++ b/sim/snapshots/evening.json @@ -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}]} \ No newline at end of file diff --git a/sim/snapshots/live.forecast.json b/sim/snapshots/live.forecast.json new file mode 100644 index 0000000..676619d --- /dev/null +++ b/sim/snapshots/live.forecast.json @@ -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}]}}} \ No newline at end of file diff --git a/sim/snapshots/live.json b/sim/snapshots/live.json new file mode 100644 index 0000000..b3e6596 --- /dev/null +++ b/sim/snapshots/live.json @@ -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}]} \ No newline at end of file diff --git a/tools/ha_discover.py b/tools/ha_discover.py new file mode 100755 index 0000000..02579d6 --- /dev/null +++ b/tools/ha_discover.py @@ -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()) diff --git a/tools/ha_snapshot.py b/tools/ha_snapshot.py new file mode 100755 index 0000000..1d62f64 --- /dev/null +++ b/tools/ha_snapshot.py @@ -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())