New panel design
This commit is contained in:
1 parent
face2432e4
commit
b5d6b4a897
30 files changed
+4540
-171
No files matched your search
@@ -3,6 +3,9 @@ build/
|
|||||||
managed_components/
|
managed_components/
|
||||||
dependencies.lock
|
dependencies.lock
|
||||||
|
|
||||||
|
# Desktop simulator build output (sim/ sources themselves ARE tracked)
|
||||||
|
sim/build/
|
||||||
|
|
||||||
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
|
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
|
||||||
sdkconfig
|
sdkconfig
|
||||||
sdkconfig.old
|
sdkconfig.old
|
||||||
|
|||||||
@@ -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
|
> **Advance**. Building it against Basic documentation yields a black screen
|
||||||
> and a silent I2C bus.
|
> and a silent I2C bus.
|
||||||
|
|
||||||
Currently displays outdoor conditions from an Ambient Weather personal weather
|
A wall dashboard driven by **Home Assistant** over the LAN: outdoor and indoor
|
||||||
station. Thermostat control (setpoint, HVAC relays, scheduling) is not
|
conditions from an Ambient Weather station, sunrise/sunset, rain, a five-day
|
||||||
implemented yet.
|
forecast, and on/off plus brightness control for six lights. Thermostat control
|
||||||
|
(setpoint, HVAC relays, scheduling) is not implemented yet.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -24,16 +25,32 @@ implemented yet.
|
|||||||
| `main/ui/lvgl_port.c` | LVGL tick source, service task, and locking |
|
| `main/ui/lvgl_port.c` | LVGL tick source, service task, and locking |
|
||||||
| `main/ui/ui_manager.c` | Screen layout and update entry points |
|
| `main/ui/ui_manager.c` | Screen layout and update entry points |
|
||||||
| `main/wifi` | WiFi station bring-up |
|
| `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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp main/app_config.h.example main/app_config.h
|
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
|
## 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
|
Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API
|
||||||
and `dma_burst_size`).
|
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)
|
## Hardware (Advance)
|
||||||
|
|
||||||
### RGB panel (16-bit RGB565)
|
### 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
|
tables pairing 120MHz PSRAM with 120MHz flash. Flash above 80MHz requires
|
||||||
High Performance Mode and a DC-aware bootloader.
|
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
|
## Gotchas
|
||||||
|
|
||||||
- **LVGL does not bubble events to parents.** An event handler on the screen
|
- **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
|
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
|
dead touch zones. Light tiles therefore carry their own handlers, and the
|
||||||
`lv_timer` instead.
|
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
|
- **`lv_label_set_text()` invalidates unconditionally**, even when the text is
|
||||||
identical — so setting an unchanged value still forces a repaint. All label
|
identical — so setting an unchanged value still forces a repaint. All label
|
||||||
updates go through `label_set_if_changed()`.
|
updates go through `label_set_if_changed()`.
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# TODO
|
# 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
|
## Done
|
||||||
|
|
||||||
The six items from the 2026-08-03 review were completed in `d4b2ab7`
|
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
|
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.
|
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
|
Setpoint, HVAC relay control, scheduling and persistence are all still
|
||||||
unimplemented — this remains a weather display. Note a separate project
|
unimplemented — this remains a weather display. Note a separate project
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ idf_component_register(
|
|||||||
"ui/lvgl_port.c"
|
"ui/lvgl_port.c"
|
||||||
"ui/ui_manager.c"
|
"ui/ui_manager.c"
|
||||||
"api/ambient_weather.c"
|
"api/ambient_weather.c"
|
||||||
|
"api/ha_client.c"
|
||||||
|
"api/ha_data.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
"wifi"
|
"wifi"
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* Home Assistant REST transport. See ha_client.h for the contract and why
|
||||||
|
* this is plain HTTP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "esp_http_client.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
#include "app_config.h"
|
||||||
|
#include "ha_client.h"
|
||||||
|
#include "ha_template.h"
|
||||||
|
|
||||||
|
static const char *TAG = "ha_client";
|
||||||
|
|
||||||
|
#define HA_URL_MAX 128
|
||||||
|
#define HA_BODY_MAX 1024
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The forecast reply is the big one: six days of conditions and temperatures,
|
||||||
|
* measured at ~1.1KB. The state template is ~400 bytes. 4KB leaves room for
|
||||||
|
* HA to grow a field without silently truncating.
|
||||||
|
*/
|
||||||
|
#define HA_RESP_MAX 4096
|
||||||
|
|
||||||
|
/* Local network, no TLS handshake, so these can be far tighter than the
|
||||||
|
* cloud client's. A LAN request that has not answered in 5s is not going to. */
|
||||||
|
#define HA_TIMEOUT_MS 5000
|
||||||
|
|
||||||
|
static char resp_buf[HA_RESP_MAX];
|
||||||
|
static size_t resp_len;
|
||||||
|
static bool resp_truncated;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kept alive between polls. Re-creating the client each time re-opens the TCP
|
||||||
|
* connection, which is cheap on a LAN but not free, and this runs inside the
|
||||||
|
* window where rendering is paused.
|
||||||
|
*/
|
||||||
|
static esp_http_client_handle_t client;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Long-lived HA tokens are JWTs -- ~180-200 characters, not the 30-40 an
|
||||||
|
* "API key" suggests. 320 leaves room for "Bearer " plus a token noticeably
|
||||||
|
* longer than the ones HA issues today.
|
||||||
|
*/
|
||||||
|
static char auth_header[320];
|
||||||
|
|
||||||
|
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
||||||
|
{
|
||||||
|
switch (evt->event_id) {
|
||||||
|
case HTTP_EVENT_ON_DATA:
|
||||||
|
/* Responses are chunked, so Content-Length is often -1; accumulate
|
||||||
|
* into a fixed buffer rather than sizing an allocation from it. */
|
||||||
|
if (resp_len + evt->data_len < sizeof(resp_buf)) {
|
||||||
|
memcpy(resp_buf + resp_len, evt->data, evt->data_len);
|
||||||
|
resp_len += evt->data_len;
|
||||||
|
} else if (!resp_truncated) {
|
||||||
|
resp_truncated = true; /* once per response, not per chunk */
|
||||||
|
ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", HA_RESP_MAX);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void client_drop(void)
|
||||||
|
{
|
||||||
|
if (client != NULL) {
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
client = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ha_client_init(void)
|
||||||
|
{
|
||||||
|
if (HA_HOST[0] == '\0' || HA_TOKEN[0] == '\0') {
|
||||||
|
ESP_LOGW(TAG, "HA_HOST or HA_TOKEN not set in main/app_config.h -- "
|
||||||
|
"Home Assistant will not be polled");
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", HA_TOKEN);
|
||||||
|
if (n < 0 || n >= (int)sizeof(auth_header)) {
|
||||||
|
ESP_LOGE(TAG, "Token too long for the auth header buffer");
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One POST. `path` and `body` are borrowed; the reply lands in resp_buf.
|
||||||
|
*
|
||||||
|
* The client is rebuilt whenever the URL changes, because the three endpoints
|
||||||
|
* this talks to (template, service call, forecast) are different paths on the
|
||||||
|
* same host and esp_http_client keeps the connection per-handle.
|
||||||
|
*/
|
||||||
|
static esp_err_t ha_post(const char *path, const char *body)
|
||||||
|
{
|
||||||
|
char url[HA_URL_MAX];
|
||||||
|
const int n = snprintf(url, sizeof(url), "http://%s:%d%s", HA_HOST, HA_PORT, path);
|
||||||
|
if (n < 0 || n >= (int)sizeof(url)) {
|
||||||
|
ESP_LOGE(TAG, "URL truncated for path %s", path);
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client == NULL) {
|
||||||
|
const esp_http_client_config_t config = {
|
||||||
|
.url = url,
|
||||||
|
.method = HTTP_METHOD_POST,
|
||||||
|
.timeout_ms = HA_TIMEOUT_MS,
|
||||||
|
.event_handler = http_event_handler,
|
||||||
|
.keep_alive_enable = true,
|
||||||
|
};
|
||||||
|
client = esp_http_client_init(&config);
|
||||||
|
if (client == NULL) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
esp_http_client_set_header(client, "Authorization", auth_header);
|
||||||
|
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||||
|
} else if (esp_http_client_set_url(client, url) != ESP_OK) {
|
||||||
|
client_drop();
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_http_client_set_method(client, HTTP_METHOD_POST);
|
||||||
|
esp_http_client_set_post_field(client, body, (int)strlen(body));
|
||||||
|
|
||||||
|
resp_len = 0;
|
||||||
|
resp_truncated = false;
|
||||||
|
|
||||||
|
esp_err_t err = esp_http_client_perform(client);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "POST %s failed: %s", path, esp_err_to_name(err));
|
||||||
|
client_drop(); /* a half-open connection would fail every later poll */
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int status = esp_http_client_get_status_code(client);
|
||||||
|
if (status != 200) {
|
||||||
|
ESP_LOGE(TAG, "HTTP %d from %s", status, path);
|
||||||
|
if (status == 401) {
|
||||||
|
ESP_LOGE(TAG, "Token rejected -- check HA_TOKEN in app_config.h");
|
||||||
|
}
|
||||||
|
client_drop();
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp_len == 0) {
|
||||||
|
/* Service calls legitimately return an empty body. */
|
||||||
|
resp_buf[0] = '\0';
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
resp_buf[resp_len] = '\0';
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ha_fetch_state(ha_data_t *data)
|
||||||
|
{
|
||||||
|
if (data == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The template is JSON-escaped by cJSON rather than by hand: it is full of
|
||||||
|
* quotes and braces, and getting the escaping subtly wrong yields a 400
|
||||||
|
* that looks like a template bug.
|
||||||
|
*/
|
||||||
|
cJSON *req = cJSON_CreateObject();
|
||||||
|
if (req == NULL) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
if (cJSON_AddStringToObject(req, "template", HA_STATE_TEMPLATE) == NULL) {
|
||||||
|
cJSON_Delete(req);
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *body = cJSON_PrintUnformatted(req);
|
||||||
|
cJSON_Delete(req);
|
||||||
|
if (body == NULL) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esp_err_t err = ha_post("/api/template", body);
|
||||||
|
cJSON_free(body);
|
||||||
|
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ha_parse_json(resp_buf, data)) {
|
||||||
|
ESP_LOGE(TAG, "State response was not valid JSON");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ha_fetch_forecast(ha_data_t *data)
|
||||||
|
{
|
||||||
|
if (data == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* return_response is required: without it HA runs the service and returns
|
||||||
|
* nothing, which looks like an empty forecast rather than a mistake. */
|
||||||
|
const esp_err_t err = ha_post(
|
||||||
|
"/api/services/weather/get_forecasts?return_response", HA_FORECAST_BODY);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ha_parse_forecast_json(resp_buf, data)) {
|
||||||
|
ESP_LOGE(TAG, "Forecast response could not be parsed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ha_light_command(int index, bool on, int16_t brightness)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= HA_LIGHT_COUNT) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha_light_cfg_t *cfg = &HA_LIGHTS[index];
|
||||||
|
const char *domain = ha_service_domain(cfg->entity_id);
|
||||||
|
|
||||||
|
char path[HA_URL_MAX];
|
||||||
|
int n = snprintf(path, sizeof(path), "/api/services/%s/turn_%s",
|
||||||
|
domain, on ? "on" : "off");
|
||||||
|
if (n < 0 || n >= (int)sizeof(path)) {
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
char body[HA_BODY_MAX];
|
||||||
|
if (on && brightness >= 0 && cfg->dimmable) {
|
||||||
|
n = snprintf(body, sizeof(body),
|
||||||
|
"{\"entity_id\":\"%s\",\"brightness\":%d}",
|
||||||
|
cfg->entity_id, brightness);
|
||||||
|
} else {
|
||||||
|
/* No brightness key: turning on without one makes HA restore the
|
||||||
|
* light's previous level, which is what a user expects from a tap. */
|
||||||
|
n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\"}", cfg->entity_id);
|
||||||
|
}
|
||||||
|
if (n < 0 || n >= (int)sizeof(body)) {
|
||||||
|
return ESP_ERR_INVALID_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "%s -> %s%s", cfg->entity_id, on ? "on" : "off",
|
||||||
|
(on && brightness >= 0 && cfg->dimmable) ? " (with brightness)" : "");
|
||||||
|
|
||||||
|
return ha_post(path, body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Home Assistant REST transport (device only).
|
||||||
|
*
|
||||||
|
* Everything ESP-IDF-specific about talking to HA lives here; parsing and
|
||||||
|
* presentation are in ha_data.c, which the desktop simulator also compiles.
|
||||||
|
*
|
||||||
|
* Plain HTTP on the LAN, deliberately. The Ambient Weather cloud client needs
|
||||||
|
* TLS and pays a multi-second handshake for it; this talks to a box on the
|
||||||
|
* same switch, and the token is not worth a handshake inside the window where
|
||||||
|
* the UI is frozen. Do not point this at an https:// HA instance without
|
||||||
|
* revisiting that trade-off.
|
||||||
|
*/
|
||||||
|
#ifndef HA_CLIENT_H
|
||||||
|
#define HA_CLIENT_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#include "ha_data.h"
|
||||||
|
|
||||||
|
/* Validates configuration. Does not open a connection. */
|
||||||
|
esp_err_t ha_client_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One request for every sensor, light and sun time (~400 bytes). Leaves
|
||||||
|
* data->forecast alone -- see ha_fetch_forecast().
|
||||||
|
*
|
||||||
|
* Blocks for the round trip, so callers pause LVGL around it.
|
||||||
|
*/
|
||||||
|
esp_err_t ha_fetch_state(ha_data_t *data);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fills data->forecast. A separate, slower poll: the forecast is a service
|
||||||
|
* call rather than a template render, and it changes hourly at most.
|
||||||
|
*/
|
||||||
|
esp_err_t ha_fetch_forecast(ha_data_t *data);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Turn a light on or off. `index` indexes HA_LIGHTS; `brightness` is 0-255,
|
||||||
|
* or -1 to leave the level alone (and always for switch-domain entities).
|
||||||
|
*
|
||||||
|
* Blocks. Must not be called from the LVGL task -- see ui_set_light_cb().
|
||||||
|
*/
|
||||||
|
esp_err_t ha_light_command(int index, bool on, int16_t brightness);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/*
|
||||||
|
* Parsing and presentation helpers for Home Assistant data.
|
||||||
|
*
|
||||||
|
* Deliberately free of ESP-IDF and network dependencies: this file is
|
||||||
|
* compiled into the desktop simulator as well as the firmware, so the layout
|
||||||
|
* work in sim/ exercises the same parser the device runs. Keep it that way --
|
||||||
|
* anything needing esp_* belongs in ha_client.c.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
|
||||||
|
#include "ha_data.h"
|
||||||
|
|
||||||
|
/* Build the config table from the same list the JSON template is built from. */
|
||||||
|
#define HA_LIGHT_ENTRY_X(id, label, dimmable) { id, label, dimmable },
|
||||||
|
const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = {
|
||||||
|
HA_LIGHT_LIST(HA_LIGHT_ENTRY_X)
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ values */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A JSON null (or a missing key) means the sensor is unavailable and the
|
||||||
|
* field stays invalid. Note cJSON_IsNumber() is false for null, so this
|
||||||
|
* handles both cases without a separate null check.
|
||||||
|
*/
|
||||||
|
static void take_number(const cJSON *root, const char *key, ha_value_t *out)
|
||||||
|
{
|
||||||
|
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||||
|
if (cJSON_IsNumber(item)) {
|
||||||
|
out->value = (float)item->valuedouble;
|
||||||
|
out->valid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void take_string(const cJSON *root, const char *key, char *out, size_t len)
|
||||||
|
{
|
||||||
|
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
||||||
|
if (cJSON_IsString(item) && item->valuestring != NULL) {
|
||||||
|
snprintf(out, len, "%s", item->valuestring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ lights */
|
||||||
|
|
||||||
|
static ha_light_state_t light_state_from_string(const char *s)
|
||||||
|
{
|
||||||
|
if (s == NULL) {
|
||||||
|
return HA_LIGHT_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
if (strcmp(s, "on") == 0) {
|
||||||
|
return HA_LIGHT_ON;
|
||||||
|
}
|
||||||
|
if (strcmp(s, "off") == 0) {
|
||||||
|
return HA_LIGHT_OFF;
|
||||||
|
}
|
||||||
|
/* "unavailable", "unknown", or anything unexpected. Treated the same:
|
||||||
|
* the tile greys out rather than claiming the light is off. */
|
||||||
|
return HA_LIGHT_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_lights(const cJSON *root, ha_data_t *out)
|
||||||
|
{
|
||||||
|
const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights");
|
||||||
|
if (!cJSON_IsArray(lights)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Index-aligned with HA_LIGHTS because the template iterates the same
|
||||||
|
* X-macro list. If HA returns a different count, something is out of sync
|
||||||
|
* between the template and the table -- take what lines up rather than
|
||||||
|
* reading past either array.
|
||||||
|
*/
|
||||||
|
const int n = cJSON_GetArraySize(lights);
|
||||||
|
const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
const cJSON *entry = cJSON_GetArrayItem(lights, i);
|
||||||
|
if (!cJSON_IsObject(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s");
|
||||||
|
out->lights[i].state = light_state_from_string(
|
||||||
|
cJSON_IsString(state) ? state->valuestring : NULL);
|
||||||
|
|
||||||
|
const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b");
|
||||||
|
out->lights[i].brightness = cJSON_IsNumber(bright)
|
||||||
|
? (int16_t)bright->valuedouble
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- state JSON */
|
||||||
|
|
||||||
|
bool ha_parse_json(const char *json, ha_data_t *out)
|
||||||
|
{
|
||||||
|
if (json == NULL || out == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *root = cJSON_Parse(json);
|
||||||
|
if (root == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preserve the forecast across the wipe. It arrives from a different,
|
||||||
|
* slower request; zeroing the whole struct here would blank the forecast
|
||||||
|
* strip on every sensor poll.
|
||||||
|
*/
|
||||||
|
ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS];
|
||||||
|
memcpy(saved_forecast, out->forecast, sizeof(saved_forecast));
|
||||||
|
const uint8_t saved_count = out->forecast_count;
|
||||||
|
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
|
memcpy(out->forecast, saved_forecast, sizeof(saved_forecast));
|
||||||
|
out->forecast_count = saved_count;
|
||||||
|
|
||||||
|
take_number(root, "out_t", &out->outdoor_temp_f);
|
||||||
|
take_number(root, "out_h", &out->outdoor_humidity);
|
||||||
|
take_number(root, "in_t", &out->indoor_temp_f);
|
||||||
|
take_number(root, "in_h", &out->indoor_humidity);
|
||||||
|
take_number(root, "dew", &out->dew_point_f);
|
||||||
|
take_number(root, "press", &out->pressure_inhg);
|
||||||
|
take_number(root, "rain_d", &out->rain_today_in);
|
||||||
|
take_number(root, "rain_r", &out->rain_rate_inhr);
|
||||||
|
take_number(root, "wind", &out->wind_mph);
|
||||||
|
take_number(root, "gust", &out->gust_mph);
|
||||||
|
take_number(root, "wdir", &out->wind_dir_deg);
|
||||||
|
|
||||||
|
take_string(root, "cond", out->condition, sizeof(out->condition));
|
||||||
|
take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise));
|
||||||
|
take_string(root, "sunset", out->sunset, sizeof(out->sunset));
|
||||||
|
take_string(root, "clock", out->clock, sizeof(out->clock));
|
||||||
|
take_string(root, "date", out->date, sizeof(out->date));
|
||||||
|
|
||||||
|
out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up"));
|
||||||
|
|
||||||
|
parse_lights(root, out);
|
||||||
|
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- forecast JSON */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence.
|
||||||
|
*
|
||||||
|
* The device has no clock, so the day name cannot come from localtime(). The
|
||||||
|
* forecast timestamps are UTC; HA returns them at 19:00Z, which is midday
|
||||||
|
* locally, so the UTC date and the local date agree. That would stop being
|
||||||
|
* true for a timestamp near midnight UTC -- if HA ever changes the hour, this
|
||||||
|
* needs the offset applied first.
|
||||||
|
*/
|
||||||
|
static void weekday_name(const char *iso, char *out, size_t len)
|
||||||
|
{
|
||||||
|
int y = 0, m = 0, d = 0;
|
||||||
|
if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) {
|
||||||
|
snprintf(out, len, "--");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeller treats January and February as months 13 and 14 of the year
|
||||||
|
* before. */
|
||||||
|
if (m < 3) {
|
||||||
|
m += 12;
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int K = y % 100;
|
||||||
|
const int J = y / 100;
|
||||||
|
const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
|
||||||
|
|
||||||
|
/* Zeller's h: 0 = Saturday. */
|
||||||
|
static const char *const names[7] = {
|
||||||
|
"Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"
|
||||||
|
};
|
||||||
|
snprintf(out, len, "%s", names[h]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ha_parse_forecast_json(const char *json, ha_data_t *out)
|
||||||
|
{
|
||||||
|
if (json == NULL || out == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *root = cJSON_Parse(json);
|
||||||
|
if (root == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
/* { "service_response": { "<entity>": { "forecast": [ ... ] } } } */
|
||||||
|
const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response");
|
||||||
|
const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL;
|
||||||
|
const cJSON *list = cJSON_IsObject(entity)
|
||||||
|
? cJSON_GetObjectItemCaseSensitive(entity, "forecast")
|
||||||
|
: NULL;
|
||||||
|
|
||||||
|
if (cJSON_IsArray(list)) {
|
||||||
|
const int n = cJSON_GetArraySize(list);
|
||||||
|
const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS;
|
||||||
|
|
||||||
|
memset(out->forecast, 0, sizeof(out->forecast));
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
const cJSON *day = cJSON_GetArrayItem(list, i);
|
||||||
|
if (!cJSON_IsObject(day)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_forecast_day_t *slot = &out->forecast[i];
|
||||||
|
|
||||||
|
const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime");
|
||||||
|
weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL,
|
||||||
|
slot->day, sizeof(slot->day));
|
||||||
|
|
||||||
|
take_string(day, "condition", slot->condition, sizeof(slot->condition));
|
||||||
|
take_number(day, "temperature", &slot->high_f);
|
||||||
|
take_number(day, "templow", &slot->low_f);
|
||||||
|
}
|
||||||
|
|
||||||
|
out->forecast_count = (uint8_t)count;
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- presentation */
|
||||||
|
|
||||||
|
const char *ha_service_domain(const char *entity_id)
|
||||||
|
{
|
||||||
|
/* The patio plug lives in the switch domain; light.turn_on on a switch
|
||||||
|
* entity is accepted by HA and silently does nothing. */
|
||||||
|
if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) {
|
||||||
|
return "switch";
|
||||||
|
}
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *slug;
|
||||||
|
const char *label;
|
||||||
|
const char *shrt;
|
||||||
|
} condition_name_t;
|
||||||
|
|
||||||
|
/* met.no condition slugs as HA reports them. `shrt` is for the forecast
|
||||||
|
* strip, where a column is about 150px wide. */
|
||||||
|
static const condition_name_t CONDITIONS[] = {
|
||||||
|
{ "clear-night", "Clear", "Clear" },
|
||||||
|
{ "cloudy", "Cloudy", "Cloudy" },
|
||||||
|
{ "exceptional", "Exceptional", "Severe" },
|
||||||
|
{ "fog", "Fog", "Fog" },
|
||||||
|
{ "hail", "Hail", "Hail" },
|
||||||
|
{ "lightning", "Thunderstorms", "Storm" },
|
||||||
|
{ "lightning-rainy", "Thunderstorms", "Storm" },
|
||||||
|
{ "partlycloudy", "Partly Cloudy", "Partly" },
|
||||||
|
{ "pouring", "Heavy Rain", "Heavy" },
|
||||||
|
{ "rainy", "Rain", "Rain" },
|
||||||
|
{ "snowy", "Snow", "Snow" },
|
||||||
|
{ "snowy-rainy", "Wintry Mix", "Wintry" },
|
||||||
|
{ "sunny", "Sunny", "Sunny" },
|
||||||
|
{ "windy", "Windy", "Windy" },
|
||||||
|
{ "windy-variant", "Windy", "Windy" },
|
||||||
|
};
|
||||||
|
|
||||||
|
static const condition_name_t *condition_lookup(const char *slug)
|
||||||
|
{
|
||||||
|
if (slug == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) {
|
||||||
|
if (strcmp(CONDITIONS[i].slug, slug) == 0) {
|
||||||
|
return &CONDITIONS[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_condition_label(const char *slug)
|
||||||
|
{
|
||||||
|
const condition_name_t *c = condition_lookup(slug);
|
||||||
|
/* Unrecognised slugs pass through rather than becoming "Unknown" -- if
|
||||||
|
* met.no adds a condition, seeing the raw slug on screen says what to
|
||||||
|
* add here. */
|
||||||
|
return (c != NULL) ? c->label : (slug != NULL ? slug : "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_condition_short(const char *slug)
|
||||||
|
{
|
||||||
|
const condition_name_t *c = condition_lookup(slug);
|
||||||
|
return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_wind_cardinal(float degrees)
|
||||||
|
{
|
||||||
|
static const char *const points[16] = {
|
||||||
|
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||||
|
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isfinite(degrees)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fmodf twice: a negative bearing would otherwise index out of bounds. */
|
||||||
|
float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f);
|
||||||
|
const int idx = (int)((d + 11.25f) / 22.5f) % 16;
|
||||||
|
return points[idx];
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* The data contract between "fetching" and "drawing".
|
||||||
|
*
|
||||||
|
* ha_data_t is plain data with no ESP-IDF or network dependency, and
|
||||||
|
* ha_parse_json() is a pure function. That is what lets the desktop simulator
|
||||||
|
* run the real UI: sim/ supplies a JSON snapshot or a LAN fetch, the firmware
|
||||||
|
* supplies esp_http_client, and both hand the identical struct to the
|
||||||
|
* identical drawing code.
|
||||||
|
*
|
||||||
|
* The UI layer must never see HTTP, cJSON or an entity ID -- only this.
|
||||||
|
*/
|
||||||
|
#ifndef HA_DATA_H
|
||||||
|
#define HA_DATA_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "ha_config.h"
|
||||||
|
|
||||||
|
/* Five days across the bottom strip. HA returns six; the sixth is dropped. */
|
||||||
|
#define HA_FORECAST_DAYS 5
|
||||||
|
|
||||||
|
/* Derived from HA_LIGHT_LIST so the two can never disagree. */
|
||||||
|
#define HA_LIGHT_COUNT_X(a, b, c) +1
|
||||||
|
#define HA_LIGHT_COUNT (0 HA_LIGHT_LIST(HA_LIGHT_COUNT_X))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A reading that may not exist.
|
||||||
|
*
|
||||||
|
* Sensors go unavailable routinely -- a dead anemometer, a flat battery, an
|
||||||
|
* integration reloading -- and a float alone cannot say so. Every consumer has
|
||||||
|
* to check `valid` before touching `value`; there is deliberately no sentinel
|
||||||
|
* to accidentally render as a real number.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
float value;
|
||||||
|
bool valid;
|
||||||
|
} ha_value_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
HA_LIGHT_OFF = 0,
|
||||||
|
HA_LIGHT_ON,
|
||||||
|
HA_LIGHT_UNAVAILABLE, /* entity exists but HA cannot reach the device */
|
||||||
|
} ha_light_state_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
ha_light_state_t state;
|
||||||
|
/* 0-255 as HA reports it. Only meaningful when state is HA_LIGHT_ON and
|
||||||
|
* the entity is dimmable; -1 otherwise. */
|
||||||
|
int16_t brightness;
|
||||||
|
} ha_light_status_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *entity_id;
|
||||||
|
const char *label;
|
||||||
|
bool dimmable;
|
||||||
|
} ha_light_cfg_t;
|
||||||
|
|
||||||
|
/* Generated from HA_LIGHT_LIST; index-aligned with ha_data_t.lights. */
|
||||||
|
extern const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT];
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char day[4]; /* "Mon" -- derived from the ISO date, see below */
|
||||||
|
char condition[24]; /* met.no condition slug, e.g. "partlycloudy" */
|
||||||
|
ha_value_t high_f;
|
||||||
|
ha_value_t low_f;
|
||||||
|
} ha_forecast_day_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
/* Weather station */
|
||||||
|
ha_value_t outdoor_temp_f;
|
||||||
|
ha_value_t outdoor_humidity;
|
||||||
|
ha_value_t indoor_temp_f;
|
||||||
|
ha_value_t indoor_humidity;
|
||||||
|
ha_value_t dew_point_f;
|
||||||
|
ha_value_t pressure_inhg;
|
||||||
|
ha_value_t rain_today_in;
|
||||||
|
ha_value_t rain_rate_inhr;
|
||||||
|
ha_value_t wind_mph;
|
||||||
|
ha_value_t gust_mph;
|
||||||
|
ha_value_t wind_dir_deg;
|
||||||
|
|
||||||
|
/* Forecast / sun. These strings arrive pre-formatted in local time --
|
||||||
|
* HA owns the timezone, so the device needs no clock and no SNTP. */
|
||||||
|
char condition[24];
|
||||||
|
char sunrise[12]; /* "6:05 AM" */
|
||||||
|
char sunset[12]; /* "7:45 PM" */
|
||||||
|
char clock[12]; /* "10:21 AM" */
|
||||||
|
char date[16]; /* "Tue Aug 4" */
|
||||||
|
bool sun_up;
|
||||||
|
|
||||||
|
ha_light_status_t lights[HA_LIGHT_COUNT];
|
||||||
|
|
||||||
|
ha_forecast_day_t forecast[HA_FORECAST_DAYS];
|
||||||
|
uint8_t forecast_count; /* 0 when no forecast has been fetched yet */
|
||||||
|
} ha_data_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse the reply to the state template into `out`.
|
||||||
|
*
|
||||||
|
* Zeroes `out` first, so a field missing from the response reads as invalid
|
||||||
|
* rather than keeping a previous poll's value. Returns false only when the
|
||||||
|
* body is not parseable JSON -- individual missing or unavailable sensors are
|
||||||
|
* normal and leave their ha_value_t invalid.
|
||||||
|
*
|
||||||
|
* Does not touch the forecast fields; those come from ha_parse_forecast_json()
|
||||||
|
* on a slower poll and would otherwise be wiped every minute.
|
||||||
|
*/
|
||||||
|
bool ha_parse_json(const char *json, ha_data_t *out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse a weather/get_forecasts service response into out->forecast.
|
||||||
|
* Fills at most HA_FORECAST_DAYS entries and sets forecast_count.
|
||||||
|
*/
|
||||||
|
bool ha_parse_forecast_json(const char *json, ha_data_t *out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "light" or "switch" for entity_id -- the HA service domain to call. The
|
||||||
|
* patio plug is a switch, and calling light.turn_on on it silently does
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
const char *ha_service_domain(const char *entity_id);
|
||||||
|
|
||||||
|
/* Human-readable form of a met.no condition slug ("partlycloudy" ->
|
||||||
|
* "Partly Cloudy"). Returns the slug unchanged if unrecognised, so an
|
||||||
|
* unhandled condition shows up on screen as the thing to go add. */
|
||||||
|
const char *ha_condition_label(const char *slug);
|
||||||
|
|
||||||
|
/* As above, abbreviated for the forecast strip's narrow columns
|
||||||
|
* ("partlycloudy" -> "Partly"). */
|
||||||
|
const char *ha_condition_short(const char *slug);
|
||||||
|
|
||||||
|
/* Compass point for a bearing in degrees ("NNE"). */
|
||||||
|
const char *ha_wind_cardinal(float degrees);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -5,15 +5,39 @@
|
|||||||
#ifndef APP_CONFIG_H
|
#ifndef APP_CONFIG_H
|
||||||
#define APP_CONFIG_H
|
#define APP_CONFIG_H
|
||||||
|
|
||||||
/* From https://ambientweather.net/account
|
#define WIFI_SSID ""
|
||||||
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */
|
#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_API_KEY ""
|
||||||
#define AMBIENT_WEATHER_APP_KEY ""
|
#define AMBIENT_WEATHER_APP_KEY ""
|
||||||
#define AMBIENT_WEATHER_DEVICE_ID ""
|
#define AMBIENT_WEATHER_DEVICE_ID ""
|
||||||
|
|
||||||
#define WIFI_SSID ""
|
/* The fallback client's own poll interval name. */
|
||||||
#define WIFI_PASSWORD ""
|
#define WEATHER_UPDATE_INTERVAL_MS HA_UPDATE_INTERVAL_MS
|
||||||
|
|
||||||
#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000)
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
+160
-56
@@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/queue.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
#include "ambient_weather.h"
|
#include "ambient_weather.h"
|
||||||
#include "app_config.h"
|
#include "app_config.h"
|
||||||
#include "backlight.h"
|
#include "backlight.h"
|
||||||
#include "display_rgb.h"
|
#include "display_rgb.h"
|
||||||
|
#include "ha_client.h"
|
||||||
|
#include "ha_data.h"
|
||||||
#include "i2c_bus.h"
|
#include "i2c_bus.h"
|
||||||
#include "lvgl_port.h"
|
#include "lvgl_port.h"
|
||||||
#include "touch_gt911.h"
|
#include "touch_gt911.h"
|
||||||
@@ -19,99 +22,187 @@
|
|||||||
static const char *TAG = "main";
|
static const char *TAG = "main";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
|
* 6144 was measured with the old cloud client, whose TLS handshake was the
|
||||||
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
|
* deepest this task ever got. The HA path is plain HTTP and shallower, but
|
||||||
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
|
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
|
||||||
* is why the handshake fits at all. Re-check the high-water-mark log if more
|
* headroom is kept. Re-check the high-water-mark log if more work lands here.
|
||||||
* work lands in this task.
|
|
||||||
*/
|
*/
|
||||||
#define WEATHER_TASK_STACK 6144
|
#define HA_TASK_STACK 6144
|
||||||
#define WEATHER_TASK_PRIO 3
|
#define HA_TASK_PRIO 3
|
||||||
#define WEATHER_FIRST_DELAY_MS 3000
|
#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. */
|
/* 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];
|
char line[96];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Age is tracked in ticks, not from weather_data_t.last_update. That field
|
* Age is tracked in ticks, not wall time: with no SNTP the system clock
|
||||||
* comes from time(NULL), and with no SNTP client the system clock starts
|
* starts at the epoch. (The clock shown on screen comes from HA, which is
|
||||||
* at the epoch -- an age derived from it would be nonsense. Tick
|
* why the panel can display the time without knowing it.) Tick
|
||||||
* subtraction is also wrap-safe.
|
* subtraction is also wrap-safe.
|
||||||
*/
|
*/
|
||||||
TickType_t last_success = 0;
|
TickType_t last_success = 0;
|
||||||
int consecutive_failures = 0;
|
int consecutive_failures = 0;
|
||||||
bool have_reading = false;
|
bool have_reading = false;
|
||||||
bool stack_logged = 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) {
|
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()) {
|
if (!wifi_is_connected()) {
|
||||||
ui_set_status("WiFi: reconnecting...");
|
ui_set_status("WiFi: reconnecting...");
|
||||||
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Freeze rendering for the duration of the fetch. The TLS session,
|
* Freeze rendering for the duration of the network work. The redraw
|
||||||
* the LVGL redraw and the flush into the PSRAM framebuffer all
|
* and the flush into the PSRAM framebuffer compete with the LCD's own
|
||||||
* compete with the LCD's own DMA out of that same PSRAM; letting them
|
* DMA out of that same PSRAM; letting them overlap is what produces
|
||||||
* overlap is what produces visible artefacts. The panel keeps
|
* visible artefacts. The panel keeps scanning the last completed
|
||||||
* scanning the last completed frame, so the screen simply holds still.
|
* frame, so the screen simply holds still.
|
||||||
*/
|
*/
|
||||||
lvgl_port_pause();
|
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();
|
lvgl_port_resume();
|
||||||
|
|
||||||
if (err == ESP_OK) {
|
poll_count++;
|
||||||
|
|
||||||
|
if (err == ESP_OK || on_fallback) {
|
||||||
last_success = xTaskGetTickCount();
|
last_success = xTaskGetTickCount();
|
||||||
consecutive_failures = 0;
|
consecutive_failures = 0;
|
||||||
have_reading = true;
|
have_reading = true;
|
||||||
|
|
||||||
weather_get_string(&weather, line, sizeof(line));
|
ui_update(&ha_data);
|
||||||
ESP_LOGI(TAG, "Weather: %s", line);
|
ui_set_stale(false);
|
||||||
|
|
||||||
char detail[64];
|
if (on_fallback) {
|
||||||
snprintf(detail, sizeof(detail), "%.0f%% humidity",
|
ui_set_status("Home Assistant unreachable - cloud weather only");
|
||||||
weather.has_humidity ? weather.humidity : 0.0f);
|
} 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) {
|
if (!stack_logged) {
|
||||||
stack_logged = true;
|
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)));
|
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
consecutive_failures++;
|
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 =
|
const uint32_t age_min =
|
||||||
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
|
((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);
|
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
|
||||||
ui_set_status(line);
|
ui_set_status(line);
|
||||||
} else {
|
} 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();
|
const esp_err_t wifi_err = wifi_init_sta();
|
||||||
if (wifi_err != ESP_OK) {
|
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
|
/* LVGL core must exist before the display and touch drivers register
|
||||||
@@ -150,7 +241,8 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(display_init());
|
ESP_ERROR_CHECK(display_init());
|
||||||
|
|
||||||
if (touch_init() != ESP_OK) {
|
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");
|
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,24 +258,36 @@ void app_main(void)
|
|||||||
wifi_get_status(status, sizeof(status));
|
wifi_get_status(status, sizeof(status));
|
||||||
ui_set_status(status);
|
ui_set_status(status);
|
||||||
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
|
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
|
||||||
/* The event handler keeps retrying in the background; the weather
|
/* The event handler keeps retrying in the background; the HA task
|
||||||
* task polls wifi_is_connected(), so start it anyway rather than
|
* polls wifi_is_connected(), so start it anyway rather than
|
||||||
* permanently disabling weather because boot raced a slow AP. */
|
* permanently disabling updates because boot raced a slow AP. */
|
||||||
ui_set_status("WiFi: connecting...");
|
ui_set_status("WiFi: connecting...");
|
||||||
} else {
|
} else {
|
||||||
/* No SSID configured (or setup failed outright) -- nothing will ever
|
/* 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");
|
ui_set_status("WiFi: not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (weather_init() != ESP_OK) {
|
if (ha_client_init() != ESP_OK) {
|
||||||
ui_set_status("Weather: no API credentials");
|
ui_set_status("Home Assistant: not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
|
/* Optional: only used when HA cannot be reached. A missing cloud API key
|
||||||
WEATHER_TASK_PRIO, NULL);
|
* 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");
|
ESP_LOGI(TAG, "System ready");
|
||||||
}
|
}
|
||||||
+602
-96
@@ -1,3 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Dashboard layout for the 800x480 panel.
|
||||||
|
*
|
||||||
|
* Five horizontal bands, sized to fill exactly one screen with no scrolling:
|
||||||
|
*
|
||||||
|
* header 42px date, clock, current condition
|
||||||
|
* body 182px outdoor temperature card + a 3x2 grid of readings
|
||||||
|
* lights 116px one tile per HA_LIGHTS entry
|
||||||
|
* forecast 86px five day columns
|
||||||
|
* status 14px WiFi / poll failures
|
||||||
|
*
|
||||||
|
* Nothing is hidden behind navigation: this is a wall panel that has to be
|
||||||
|
* readable from across the room at a glance.
|
||||||
|
*
|
||||||
|
* Compiled into the desktop simulator as well as the firmware, so no driver,
|
||||||
|
* socket or ESP-IDF calls belong here. See sim/README.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
@@ -12,24 +31,101 @@ static const char *TAG = "ui_manager";
|
|||||||
#define UI_LOCK_TIMEOUT_MS 1000
|
#define UI_LOCK_TIMEOUT_MS 1000
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Bring-up diagnostic: a live readout of the last touch coordinate. Off by
|
* Band heights.
|
||||||
* default -- define UI_DEBUG_TOUCH to put it back on screen when working on
|
*
|
||||||
* the input path.
|
* 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;
|
/* Palette. Kept together so a retheme is one edit, not thirty. */
|
||||||
static lv_obj_t *cond_label = NULL;
|
#define COL_BG 0x131C28
|
||||||
static lv_obj_t *status_label = NULL;
|
#define COL_PANEL 0x1E2A3A
|
||||||
#ifdef UI_DEBUG_TOUCH
|
#define COL_PANEL_HI 0x27384D
|
||||||
static lv_obj_t *touch_label = NULL;
|
#define COL_ACCENT 0x3498DB
|
||||||
#endif
|
#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
|
* A reading with no value renders as a dash rather than 0 or a blank. Blank
|
||||||
* new text is identical -- which would repaint the temperature every poll
|
* reads as "this panel is broken"; 0 reads as a measurement. Neither is true
|
||||||
* whether or not it moved. Comparing first means an unchanged value costs
|
* when a sensor is unavailable -- which for the wind sensors is the normal
|
||||||
* nothing: no invalidation, so no redraw and no flush.
|
* 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)
|
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);
|
lv_label_set_text(label, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* Formats a reading, or NO_VALUE when the sensor had nothing to say. */
|
||||||
* NOTE, even with the readout compiled out: LVGL does not bubble events to
|
static void format_value(const ha_value_t *v, const char *fmt, char *buf, size_t len)
|
||||||
* 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)
|
|
||||||
{
|
{
|
||||||
lv_indev_t *indev = lv_indev_get_next(NULL);
|
if (v->valid) {
|
||||||
if (indev == NULL || touch_label == NULL) {
|
snprintf(buf, len, fmt, v->value);
|
||||||
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);
|
|
||||||
} else {
|
} 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)) {
|
lv_obj_t *p = lv_obj_create(parent);
|
||||||
ESP_LOGW(TAG, "Dropped weather update: LVGL lock timed out");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
char buf[32];
|
/* Unavailable entities are not toggleable; the tile is styled to show it,
|
||||||
snprintf(buf, sizeof(buf), "%d°F", temp_f);
|
* 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(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();
|
lvgl_port_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ui_set_weather_stale(bool stale)
|
void ui_set_stale(bool stale)
|
||||||
{
|
{
|
||||||
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
||||||
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
|
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
|
/* Unlike lv_label_set_text(), setting an identical style value does
|
||||||
* not invalidate, so this is safe to call on every poll. */
|
* not invalidate, so this is safe to call on every poll. */
|
||||||
lv_obj_set_style_text_color(temp_label,
|
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);
|
LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +657,11 @@ void ui_set_status(const char *status)
|
|||||||
lvgl_port_unlock();
|
lvgl_port_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ui_set_light_cb(ui_light_cmd_cb_t cb)
|
||||||
|
{
|
||||||
|
light_cb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
void ui_init(void)
|
void ui_init(void)
|
||||||
{
|
{
|
||||||
/* ui_init() runs from app_main, not the LVGL task, so it needs the lock
|
/* 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_t *scr = lv_screen_active();
|
||||||
lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A2332), 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, 0, 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_flow(scr, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_flex_align(scr, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
LV_FLEX_ALIGN_CENTER);
|
|
||||||
|
|
||||||
lv_obj_t *header = lv_obj_create(scr);
|
build_header(scr);
|
||||||
lv_obj_set_size(header, LV_PCT(100), 64);
|
build_body(scr);
|
||||||
lv_obj_set_style_radius(header, 0, LV_PART_MAIN);
|
build_lights(scr);
|
||||||
lv_obj_set_style_bg_color(header, lv_color_hex(0x0D1620), LV_PART_MAIN);
|
build_forecast(scr);
|
||||||
lv_obj_set_style_border_width(header, 0, LV_PART_MAIN);
|
|
||||||
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
|
|
||||||
lv_obj_t *title = lv_label_create(header);
|
/* Its own band, accounted for in the BAND_* budget. Usually says
|
||||||
lv_label_set_text(title, "Thermostat");
|
* something dull about WiFi; when a poll starts failing it is the only
|
||||||
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
|
* place that says so. */
|
||||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_28, LV_PART_MAIN);
|
status_label = label_create(scr, "Starting...", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
lv_obj_center(title);
|
lv_obj_set_height(status_label, BAND_STATUS_H);
|
||||||
|
lv_obj_set_width(status_label, LV_PCT(100));
|
||||||
lv_obj_t *card = lv_obj_create(scr);
|
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||||
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
|
|
||||||
|
|
||||||
lvgl_port_unlock();
|
lvgl_port_unlock();
|
||||||
|
|
||||||
ESP_LOGI(TAG, "UI initialized");
|
ESP_LOGI(TAG, "UI initialized (%d lights, %d forecast days)",
|
||||||
|
HA_LIGHT_COUNT, HA_FORECAST_DAYS);
|
||||||
}
|
}
|
||||||
+31
-3
@@ -3,19 +3,47 @@
|
|||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "ha_data.h"
|
||||||
#include "lvgl.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);
|
void ui_init(void);
|
||||||
|
|
||||||
/* Safe to call from any task -- these take the LVGL lock internally. */
|
/* 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);
|
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,
|
* so a value that stopped updating is visibly distinct from a live one,
|
||||||
* rather than sitting there at full styling looking current.
|
* 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
|
#endif
|
||||||
+27
-1
@@ -30,9 +30,35 @@ main/
|
|||||||
│ ├── ui_manager.h
|
│ ├── ui_manager.h
|
||||||
│ └── ui_manager.c
|
│ └── ui_manager.c
|
||||||
└── api/
|
└── 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.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.
|
Note: main/ subdirectories are plain source folders, not ESP-IDF components.
|
||||||
IDF only auto-discovers components under a top-level components/ directory,
|
IDF only auto-discovers components under a top-level components/ directory,
|
||||||
so all sources are registered by main/CMakeLists.txt.
|
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.
|
||||||
@@ -76,8 +76,18 @@ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
|
|||||||
CONFIG_LV_COLOR_DEPTH_16=y
|
CONFIG_LV_COLOR_DEPTH_16=y
|
||||||
CONFIG_LV_USE_LOG=y
|
CONFIG_LV_USE_LOG=y
|
||||||
CONFIG_LV_LOG_LEVEL_WARN=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_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_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
|
CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||||
|
|
||||||
# --- Main task needs headroom for driver init ---
|
# --- Main task needs headroom for driver init ---
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Desktop simulator for the thermostat UI. Not part of the ESP-IDF build --
|
||||||
|
# `idf.py build` never looks in here, and this never builds firmware sources
|
||||||
|
# that touch hardware.
|
||||||
|
#
|
||||||
|
# cmake -S sim -B sim/build -G Ninja && cmake --build sim/build
|
||||||
|
#
|
||||||
|
# See sim/README.md.
|
||||||
|
|
||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(thermostat_sim C)
|
||||||
|
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
|
set(FW_ROOT ${CMAKE_CURRENT_LIST_DIR}/..)
|
||||||
|
set(LVGL_DIR ${FW_ROOT}/managed_components/lvgl__lvgl)
|
||||||
|
|
||||||
|
# LVGL is pulled in by the IDF component manager, so it lives in a gitignored
|
||||||
|
# directory that only exists after an IDF build. Using it anyway -- rather than
|
||||||
|
# fetching a second copy -- guarantees the simulator and the firmware render
|
||||||
|
# with the identical LVGL version. A simulator on a different version is a
|
||||||
|
# simulator that lies.
|
||||||
|
if(NOT EXISTS ${LVGL_DIR}/lvgl.h)
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"LVGL not found at ${LVGL_DIR}\n"
|
||||||
|
"It is fetched by the ESP-IDF component manager. Run once:\n"
|
||||||
|
" . ~/esp/esp-idf/export.sh && idf.py reconfigure")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Nothing here uses LVGL's bundled demos or examples, and they add real build
|
||||||
|
# time. ThorVG is a C++ vector engine we do not use either.
|
||||||
|
set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE)
|
||||||
|
|
||||||
|
# LVGL's desktop CMake picks up lv_conf.h from CMAKE_SOURCE_DIR, which is this
|
||||||
|
# directory.
|
||||||
|
add_subdirectory(${LVGL_DIR} ${CMAKE_BINARY_DIR}/lvgl)
|
||||||
|
|
||||||
|
find_package(SDL2 REQUIRED)
|
||||||
|
|
||||||
|
# cJSON comes from the ESP-IDF tree rather than the host's libcjson, so the
|
||||||
|
# simulator parses Home Assistant replies with the exact same parser the
|
||||||
|
# firmware uses. IDF_PATH is only needed for this one directory -- the rest of
|
||||||
|
# the simulator builds with no ESP-IDF environment at all.
|
||||||
|
if(DEFINED ENV{IDF_PATH})
|
||||||
|
set(IDF_ROOT $ENV{IDF_PATH})
|
||||||
|
else()
|
||||||
|
set(IDF_ROOT $ENV{HOME}/esp/esp-idf)
|
||||||
|
endif()
|
||||||
|
set(CJSON_DIR ${IDF_ROOT}/components/json/cJSON)
|
||||||
|
|
||||||
|
if(NOT EXISTS ${CJSON_DIR}/cJSON.c)
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"cJSON not found at ${CJSON_DIR}\n"
|
||||||
|
"Set IDF_PATH, or install ESP-IDF to ~/esp/esp-idf.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(thermostat_sim
|
||||||
|
sim_main.c
|
||||||
|
lvgl_port_sim.c
|
||||||
|
compat/esp_compat.c
|
||||||
|
|
||||||
|
# The point of the exercise: real firmware sources, compiled unmodified.
|
||||||
|
# Anything added here must stay free of hardware access -- drivers and
|
||||||
|
# HTTP transport stay on the device side.
|
||||||
|
${FW_ROOT}/main/ui/ui_manager.c
|
||||||
|
${FW_ROOT}/main/api/ha_data.c
|
||||||
|
|
||||||
|
${CJSON_DIR}/cJSON.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(thermostat_sim PRIVATE
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins
|
||||||
|
${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h
|
||||||
|
${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h
|
||||||
|
${CJSON_DIR}
|
||||||
|
${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode
|
||||||
|
${SDL2_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
# SDL2_LIBRARIES can carry trailing whitespace from sdl2-config, which makes
|
||||||
|
# the link line fail in confusing ways.
|
||||||
|
string(STRIP "${SDL2_LIBRARIES}" SDL2_LIBRARIES)
|
||||||
|
|
||||||
|
target_link_libraries(thermostat_sim PRIVATE lvgl ${SDL2_LIBRARIES} m pthread)
|
||||||
|
|
||||||
|
target_compile_options(thermostat_sim PRIVATE -Wall -Wextra -Wno-unused-parameter)
|
||||||
+109
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
const char *esp_err_to_name(esp_err_t code)
|
||||||
|
{
|
||||||
|
switch (code) {
|
||||||
|
case ESP_OK: return "ESP_OK";
|
||||||
|
case ESP_FAIL: return "ESP_FAIL";
|
||||||
|
case ESP_ERR_NO_MEM: return "ESP_ERR_NO_MEM";
|
||||||
|
case ESP_ERR_INVALID_ARG: return "ESP_ERR_INVALID_ARG";
|
||||||
|
case ESP_ERR_INVALID_STATE: return "ESP_ERR_INVALID_STATE";
|
||||||
|
case ESP_ERR_INVALID_SIZE: return "ESP_ERR_INVALID_SIZE";
|
||||||
|
case ESP_ERR_NOT_FOUND: return "ESP_ERR_NOT_FOUND";
|
||||||
|
case ESP_ERR_NOT_SUPPORTED: return "ESP_ERR_NOT_SUPPORTED";
|
||||||
|
case ESP_ERR_TIMEOUT: return "ESP_ERR_TIMEOUT";
|
||||||
|
case ESP_ERR_INVALID_RESPONSE: return "ESP_ERR_INVALID_RESPONSE";
|
||||||
|
default: return "ESP_ERR_UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr)
|
||||||
|
{
|
||||||
|
fprintf(stderr, "ESP_ERROR_CHECK failed: %s (0x%x) at %s:%d\n expression: %s\n",
|
||||||
|
esp_err_to_name(rc), rc, file, line, expr);
|
||||||
|
abort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Host stand-in for ESP-IDF's esp_err.h.
|
||||||
|
*
|
||||||
|
* The simulator compiles the real firmware UI sources unmodified. Those
|
||||||
|
* sources include ESP-IDF headers, so the simulator supplies its own versions
|
||||||
|
* on the include path rather than sprinkling #ifdefs through main/. Only the
|
||||||
|
* surface the shared sources actually use is defined here -- if a new firmware
|
||||||
|
* source is added to the simulator build and fails to compile, extend this
|
||||||
|
* file, do not fork the firmware source.
|
||||||
|
*/
|
||||||
|
#ifndef ESP_ERR_H
|
||||||
|
#define ESP_ERR_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
typedef int esp_err_t;
|
||||||
|
|
||||||
|
#define ESP_OK 0
|
||||||
|
#define ESP_FAIL -1
|
||||||
|
|
||||||
|
#define ESP_ERR_NO_MEM 0x101
|
||||||
|
#define ESP_ERR_INVALID_ARG 0x102
|
||||||
|
#define ESP_ERR_INVALID_STATE 0x103
|
||||||
|
#define ESP_ERR_INVALID_SIZE 0x104
|
||||||
|
#define ESP_ERR_NOT_FOUND 0x105
|
||||||
|
#define ESP_ERR_NOT_SUPPORTED 0x106
|
||||||
|
#define ESP_ERR_TIMEOUT 0x107
|
||||||
|
#define ESP_ERR_INVALID_RESPONSE 0x108
|
||||||
|
|
||||||
|
const char *esp_err_to_name(esp_err_t code);
|
||||||
|
|
||||||
|
/* Aborts on the host too -- a failed ESP_ERROR_CHECK is a bug worth crashing
|
||||||
|
* on in the simulator exactly as it is on the device. */
|
||||||
|
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr);
|
||||||
|
|
||||||
|
#define ESP_ERROR_CHECK(x) \
|
||||||
|
do { \
|
||||||
|
const esp_err_t err_rc_ = (x); \
|
||||||
|
if (err_rc_ != ESP_OK) { \
|
||||||
|
esp_error_check_failed(err_rc_, __FILE__, __LINE__, #x); \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Host stand-in for ESP-IDF's esp_log.h -- see compat/esp_err.h for why these
|
||||||
|
* shims exist. Logs go to stderr so that stdout stays clean for anything the
|
||||||
|
* simulator wants to emit as data.
|
||||||
|
*/
|
||||||
|
#ifndef ESP_LOG_H
|
||||||
|
#define ESP_LOG_H
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* ANSI colours matched to idf.py monitor, so simulator output reads the same
|
||||||
|
* way device output does. */
|
||||||
|
#define ESP_LOG_COLOR_E "\033[0;31m"
|
||||||
|
#define ESP_LOG_COLOR_W "\033[0;33m"
|
||||||
|
#define ESP_LOG_COLOR_I "\033[0;32m"
|
||||||
|
#define ESP_LOG_COLOR_D "\033[0;36m"
|
||||||
|
#define ESP_LOG_RESET "\033[0m"
|
||||||
|
|
||||||
|
#define ESP_LOG_LEVEL_LOCAL(colour, letter, tag, fmt, ...) \
|
||||||
|
fprintf(stderr, colour letter " (sim) %s: " fmt ESP_LOG_RESET "\n", tag, ##__VA_ARGS__)
|
||||||
|
|
||||||
|
#define ESP_LOGE(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_E, "E", tag, fmt, ##__VA_ARGS__)
|
||||||
|
#define ESP_LOGW(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_W, "W", tag, fmt, ##__VA_ARGS__)
|
||||||
|
#define ESP_LOGI(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_I, "I", tag, fmt, ##__VA_ARGS__)
|
||||||
|
#define ESP_LOGD(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "D", tag, fmt, ##__VA_ARGS__)
|
||||||
|
#define ESP_LOGV(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "V", tag, fmt, ##__VA_ARGS__)
|
||||||
|
|
||||||
|
#endif
|
||||||
+1530
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Host implementation of the firmware's lvgl_port.h contract.
|
||||||
|
*
|
||||||
|
* The firmware version (main/ui/lvgl_port.c) is built on FreeRTOS tasks and
|
||||||
|
* esp_timer. This file provides the same six entry points on pthreads so that
|
||||||
|
* main/ui/ui_manager.c -- the file whose layout we actually iterate on --
|
||||||
|
* compiles and runs unchanged on the desktop.
|
||||||
|
*
|
||||||
|
* The one deliberate behavioural difference is lvgl_port_start(): on the
|
||||||
|
* device it spawns the task that calls lv_timer_handler(), whereas here the
|
||||||
|
* simulator's own main loop drives LVGL. See the comment on that function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "lvgl.h"
|
||||||
|
|
||||||
|
#include "lvgl_port.h"
|
||||||
|
|
||||||
|
static const char *TAG = "lvgl_port_sim";
|
||||||
|
|
||||||
|
static pthread_mutex_t lvgl_mutex;
|
||||||
|
static bool mutex_ready = false;
|
||||||
|
static volatile bool paused = false;
|
||||||
|
|
||||||
|
/* LVGL's millisecond time base. The firmware drives this from a 2ms esp_timer
|
||||||
|
* calling lv_tick_inc(); on the host a monotonic clock read is both simpler
|
||||||
|
* and more accurate. */
|
||||||
|
static uint32_t tick_get_cb(void)
|
||||||
|
{
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (uint32_t)(ts.tv_sec * 1000U + ts.tv_nsec / 1000000U);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t lvgl_port_init(void)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* Recursive, matching the device: lv_timer_handler() runs callbacks that
|
||||||
|
* may re-enter code which also takes the lock. Keeping the same semantics
|
||||||
|
* here means a deadlock introduced in shared UI code reproduces in the
|
||||||
|
* simulator instead of only showing up on hardware.
|
||||||
|
*/
|
||||||
|
pthread_mutexattr_t attr;
|
||||||
|
pthread_mutexattr_init(&attr);
|
||||||
|
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
|
||||||
|
|
||||||
|
if (pthread_mutex_init(&lvgl_mutex, &attr) != 0) {
|
||||||
|
pthread_mutexattr_destroy(&attr);
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
pthread_mutexattr_destroy(&attr);
|
||||||
|
mutex_ready = true;
|
||||||
|
|
||||||
|
lv_init();
|
||||||
|
lv_tick_set_cb(tick_get_cb);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "LVGL %d.%d.%d initialized (simulator)",
|
||||||
|
lv_version_major(), lv_version_minor(), lv_version_patch());
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* No-op by design.
|
||||||
|
*
|
||||||
|
* On the device this starts the tick timer and the LVGL service task. Here the
|
||||||
|
* tick is a clock callback installed in lvgl_port_init(), and lv_timer_handler()
|
||||||
|
* is called from the simulator's main loop -- running it from a second thread
|
||||||
|
* as well would mean two threads rendering into one framebuffer.
|
||||||
|
*
|
||||||
|
* It still exists so that shared start-up code can call it unconditionally.
|
||||||
|
*/
|
||||||
|
esp_err_t lvgl_port_start(void)
|
||||||
|
{
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void lvgl_port_pause(void)
|
||||||
|
{
|
||||||
|
paused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void lvgl_port_resume(void)
|
||||||
|
{
|
||||||
|
paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lvgl_port_is_paused(void)
|
||||||
|
{
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lvgl_port_lock(int timeout_ms)
|
||||||
|
{
|
||||||
|
if (!mutex_ready) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout_ms < 0) {
|
||||||
|
return pthread_mutex_lock(&lvgl_mutex) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct timespec deadline;
|
||||||
|
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||||
|
deadline.tv_sec += timeout_ms / 1000;
|
||||||
|
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
|
||||||
|
if (deadline.tv_nsec >= 1000000000L) {
|
||||||
|
deadline.tv_sec++;
|
||||||
|
deadline.tv_nsec -= 1000000000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pthread_mutex_timedlock(&lvgl_mutex, &deadline) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void lvgl_port_unlock(void)
|
||||||
|
{
|
||||||
|
if (mutex_ready) {
|
||||||
|
pthread_mutex_unlock(&lvgl_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
/*
|
||||||
|
* Desktop simulator entry point.
|
||||||
|
*
|
||||||
|
* Runs the real firmware UI (main/ui/ui_manager.c) against desktop LVGL at the
|
||||||
|
* panel's exact geometry and colour depth, so layout work does not need a
|
||||||
|
* flash cycle. Two modes:
|
||||||
|
*
|
||||||
|
* ./thermostat_sim interactive SDL window, mouse = touch
|
||||||
|
* ./thermostat_sim --png shot.png render one frame headless, write PNG
|
||||||
|
*
|
||||||
|
* The PNG mode exists so a layout change can be inspected without a human
|
||||||
|
* looking at a screen -- useful in CI, and it is how UI changes get checked
|
||||||
|
* during development.
|
||||||
|
*
|
||||||
|
* What this does NOT simulate: PSRAM bandwidth, the bounce-buffer refill ISR,
|
||||||
|
* or the RGB panel's scan timing. The left-edge artefacts described in the
|
||||||
|
* README are invisible here by construction. This tool answers "does the
|
||||||
|
* layout look right", never "does the panel keep up".
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "lvgl.h"
|
||||||
|
#include "lodepng.h"
|
||||||
|
|
||||||
|
#include "ha_data.h"
|
||||||
|
#include "ha_template.h"
|
||||||
|
#include "lvgl_port.h"
|
||||||
|
#include "ui_manager.h"
|
||||||
|
|
||||||
|
/* Must match the physical panel: main/drivers/display/display_rgb.h. A
|
||||||
|
* simulator at a different resolution would validate a layout that does not
|
||||||
|
* fit the hardware. */
|
||||||
|
#define PANEL_H_RES 800
|
||||||
|
#define PANEL_V_RES 480
|
||||||
|
|
||||||
|
static const char *TAG = "sim";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- headless */
|
||||||
|
|
||||||
|
static uint8_t *headless_fb = NULL;
|
||||||
|
|
||||||
|
static void headless_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
|
||||||
|
{
|
||||||
|
/* Rendering happens straight into headless_fb (DIRECT mode), so there is
|
||||||
|
* nothing to copy -- just tell LVGL the "transfer" is done. */
|
||||||
|
(void)area;
|
||||||
|
(void)px_map;
|
||||||
|
lv_display_flush_ready(disp);
|
||||||
|
}
|
||||||
|
|
||||||
|
static lv_display_t *headless_display_create(void)
|
||||||
|
{
|
||||||
|
const size_t fb_bytes = (size_t)PANEL_H_RES * PANEL_V_RES * 2; /* RGB565 */
|
||||||
|
|
||||||
|
headless_fb = malloc(fb_bytes);
|
||||||
|
if (headless_fb == NULL) {
|
||||||
|
fprintf(stderr, "%s: cannot allocate %zu byte framebuffer\n", TAG, fb_bytes);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
memset(headless_fb, 0, fb_bytes);
|
||||||
|
|
||||||
|
lv_display_t *disp = lv_display_create(PANEL_H_RES, PANEL_V_RES);
|
||||||
|
if (disp == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_display_set_flush_cb(disp, headless_flush_cb);
|
||||||
|
|
||||||
|
/* DIRECT mode with one full-screen buffer, matching the firmware's render
|
||||||
|
* mode so widget clipping and partial-redraw behaviour line up. */
|
||||||
|
lv_display_set_buffers(disp, headless_fb, NULL, fb_bytes,
|
||||||
|
LV_DISPLAY_RENDER_MODE_DIRECT);
|
||||||
|
|
||||||
|
return disp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RGB565 little-endian -> RGBA8888, then out to a PNG via LVGL's bundled
|
||||||
|
* lodepng. Channels are scaled (not just shifted) so a saturated 0x1F maps to
|
||||||
|
* 255 rather than 248 -- otherwise every screenshot reads slightly dark. */
|
||||||
|
static int headless_write_png(const char *path)
|
||||||
|
{
|
||||||
|
const size_t px_count = (size_t)PANEL_H_RES * PANEL_V_RES;
|
||||||
|
|
||||||
|
uint8_t *rgba = malloc(px_count * 4);
|
||||||
|
if (rgba == NULL) {
|
||||||
|
fprintf(stderr, "%s: cannot allocate RGBA conversion buffer\n", TAG);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t *src = (const uint16_t *)headless_fb;
|
||||||
|
for (size_t i = 0; i < px_count; i++) {
|
||||||
|
const uint16_t p = src[i];
|
||||||
|
const uint8_t r5 = (uint8_t)((p >> 11) & 0x1F);
|
||||||
|
const uint8_t g6 = (uint8_t)((p >> 5) & 0x3F);
|
||||||
|
const uint8_t b5 = (uint8_t)(p & 0x1F);
|
||||||
|
|
||||||
|
rgba[i * 4 + 0] = (uint8_t)((r5 * 255 + 15) / 31);
|
||||||
|
rgba[i * 4 + 1] = (uint8_t)((g6 * 255 + 31) / 63);
|
||||||
|
rgba[i * 4 + 2] = (uint8_t)((b5 * 255 + 15) / 31);
|
||||||
|
rgba[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Encode to memory and write with stdio, rather than lodepng_encode32_file().
|
||||||
|
* LVGL patches its bundled lodepng to do file I/O through lv_fs, which
|
||||||
|
* would mean registering an LVGL filesystem driver and passing
|
||||||
|
* drive-letter paths like "A:/tmp/shot.png". Not worth it to save an
|
||||||
|
* fwrite.
|
||||||
|
*/
|
||||||
|
uint8_t *png = NULL;
|
||||||
|
size_t png_len = 0;
|
||||||
|
const unsigned err = lodepng_encode32(&png, &png_len, rgba, PANEL_H_RES, PANEL_V_RES);
|
||||||
|
free(rgba);
|
||||||
|
|
||||||
|
if (err != 0) {
|
||||||
|
fprintf(stderr, "%s: PNG encode failed: %s\n", TAG, lodepng_error_text(err));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE *f = fopen(path, "wb");
|
||||||
|
if (f == NULL) {
|
||||||
|
perror(path);
|
||||||
|
free(png); /* LV_USE_STDLIB_MALLOC is CLIB here, so lv_free == free */
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t written = fwrite(png, 1, png_len, f);
|
||||||
|
const bool short_write = (written != png_len);
|
||||||
|
const bool close_failed = (fclose(f) != 0);
|
||||||
|
free(png);
|
||||||
|
|
||||||
|
if (short_write || close_failed) {
|
||||||
|
fprintf(stderr, "%s: writing %s failed\n", TAG, path);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("wrote %s (%dx%d, %zu bytes)\n", path, PANEL_H_RES, PANEL_V_RES, png_len);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- data */
|
||||||
|
|
||||||
|
/* The state the UI is currently showing. Snapshots load into this, and the
|
||||||
|
* light callback mutates it so the simulator responds to clicks with no Home
|
||||||
|
* Assistant present. */
|
||||||
|
static ha_data_t sim_data;
|
||||||
|
|
||||||
|
static char *read_file(const char *path)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (f == NULL) {
|
||||||
|
perror(path);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
fseek(f, 0, SEEK_END);
|
||||||
|
const long len = ftell(f);
|
||||||
|
rewind(f);
|
||||||
|
|
||||||
|
if (len < 0) {
|
||||||
|
fclose(f);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *buf = malloc((size_t)len + 1);
|
||||||
|
if (buf == NULL) {
|
||||||
|
fclose(f);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t got = fread(buf, 1, (size_t)len, f);
|
||||||
|
fclose(f);
|
||||||
|
buf[got] = '\0';
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool load_snapshot(const char *path, bool forecast)
|
||||||
|
{
|
||||||
|
char *json = read_file(path);
|
||||||
|
if (json == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool ok = forecast ? ha_parse_forecast_json(json, &sim_data)
|
||||||
|
: ha_parse_json(json, &sim_data);
|
||||||
|
free(json);
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
fprintf(stderr, "%s: %s is not valid %s JSON\n", TAG, path,
|
||||||
|
forecast ? "forecast" : "state");
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Enough of a populated screen to lay out against when no snapshot is given.
|
||||||
|
* Deliberately includes the awkward cases: the wind sensor is offline (as it
|
||||||
|
* genuinely is), one light is unavailable, and one is dimmed.
|
||||||
|
*/
|
||||||
|
static void apply_mock_data(void)
|
||||||
|
{
|
||||||
|
memset(&sim_data, 0, sizeof(sim_data));
|
||||||
|
|
||||||
|
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
||||||
|
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
|
||||||
|
sim_data.indoor_temp_f = (ha_value_t){ 78.1f, true };
|
||||||
|
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
|
||||||
|
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
||||||
|
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
|
||||||
|
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
|
||||||
|
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
|
||||||
|
/* wind/gust/wdir left invalid on purpose -- that is the real state */
|
||||||
|
|
||||||
|
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
||||||
|
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
||||||
|
snprintf(sim_data.sunset, sizeof(sim_data.sunset), "7:45 PM");
|
||||||
|
snprintf(sim_data.clock, sizeof(sim_data.clock), "10:25 AM");
|
||||||
|
snprintf(sim_data.date, sizeof(sim_data.date), "Tue Aug 4");
|
||||||
|
sim_data.sun_up = true;
|
||||||
|
|
||||||
|
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||||
|
sim_data.lights[i].state = HA_LIGHT_OFF;
|
||||||
|
sim_data.lights[i].brightness = -1;
|
||||||
|
}
|
||||||
|
sim_data.lights[0].state = HA_LIGHT_ON;
|
||||||
|
sim_data.lights[0].brightness = 178; /* 70% */
|
||||||
|
sim_data.lights[4].state = HA_LIGHT_UNAVAILABLE;
|
||||||
|
|
||||||
|
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
||||||
|
static const char *const cond[HA_FORECAST_DAYS] = {
|
||||||
|
"partlycloudy", "sunny", "sunny", "clear-night", "rainy"
|
||||||
|
};
|
||||||
|
static const float hi[HA_FORECAST_DAYS] = { 80, 80, 82, 82, 74 };
|
||||||
|
static const float lo[HA_FORECAST_DAYS] = { 70, 67, 68, 69, 63 };
|
||||||
|
|
||||||
|
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
|
||||||
|
snprintf(sim_data.forecast[i].day, sizeof(sim_data.forecast[i].day), "%s", days[i]);
|
||||||
|
snprintf(sim_data.forecast[i].condition, sizeof(sim_data.forecast[i].condition),
|
||||||
|
"%s", cond[i]);
|
||||||
|
sim_data.forecast[i].high_f = (ha_value_t){ hi[i], true };
|
||||||
|
sim_data.forecast[i].low_f = (ha_value_t){ lo[i], true };
|
||||||
|
}
|
||||||
|
sim_data.forecast_count = HA_FORECAST_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stands in for the firmware's HTTP command path. Applying the change locally
|
||||||
|
* means tiles respond to clicks in the simulator; on the device the real state
|
||||||
|
* comes back from Home Assistant on the next poll instead.
|
||||||
|
*/
|
||||||
|
static void sim_light_cmd(int index, bool on, int16_t brightness)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= HA_LIGHT_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "light cmd: %-52s %s brightness=%d\n",
|
||||||
|
HA_LIGHTS[index].entity_id, on ? "ON " : "OFF", brightness);
|
||||||
|
|
||||||
|
sim_data.lights[index].state = on ? HA_LIGHT_ON : HA_LIGHT_OFF;
|
||||||
|
if (brightness >= 0) {
|
||||||
|
sim_data.lights[index].brightness = brightness;
|
||||||
|
} else if (on && sim_data.lights[index].brightness < 0) {
|
||||||
|
/* Turning on without a brightness: HA restores the previous level.
|
||||||
|
* Full is a reasonable stand-in for that here. */
|
||||||
|
sim_data.lights[index].brightness = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_update(&sim_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- main */
|
||||||
|
|
||||||
|
static void print_usage(const char *argv0)
|
||||||
|
{
|
||||||
|
printf("usage: %s [--png FILE] [--settle-ms N] [--data FILE] [--forecast FILE]\n"
|
||||||
|
"\n"
|
||||||
|
" (no arguments) interactive SDL window, mouse acts as touch\n"
|
||||||
|
" --png FILE render one frame headless and write it to FILE\n"
|
||||||
|
" --settle-ms N ms of LVGL time before the PNG capture (default 400)\n"
|
||||||
|
" --data FILE state JSON as returned by HA's /api/template\n"
|
||||||
|
" --forecast FILE response JSON from weather/get_forecasts\n"
|
||||||
|
" --print-template emit the Jinja template the firmware uses, then exit\n"
|
||||||
|
"\n"
|
||||||
|
"Without --data a built-in mock is used. Capture real snapshots with\n"
|
||||||
|
"tools/ha_snapshot.py to iterate against states that actually occur.\n",
|
||||||
|
argv0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
const char *png_path = NULL;
|
||||||
|
const char *data_path = NULL;
|
||||||
|
const char *forecast_path = NULL;
|
||||||
|
int settle_ms = 400;
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
if (strcmp(argv[i], "--png") == 0 && i + 1 < argc) {
|
||||||
|
png_path = argv[++i];
|
||||||
|
} else if (strcmp(argv[i], "--settle-ms") == 0 && i + 1 < argc) {
|
||||||
|
settle_ms = atoi(argv[++i]);
|
||||||
|
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
|
||||||
|
data_path = argv[++i];
|
||||||
|
} else if (strcmp(argv[i], "--forecast") == 0 && i + 1 < argc) {
|
||||||
|
forecast_path = argv[++i];
|
||||||
|
} else if (strcmp(argv[i], "--print-template") == 0) {
|
||||||
|
/*
|
||||||
|
* Lets tools/ha_snapshot.py capture snapshots using the exact
|
||||||
|
* template the firmware compiles, instead of a hand-copied
|
||||||
|
* duplicate that silently drifts the first time an entity ID
|
||||||
|
* changes. The preprocessor is the single source of truth.
|
||||||
|
*/
|
||||||
|
printf("%s\n", HA_STATE_TEMPLATE);
|
||||||
|
return 0;
|
||||||
|
} else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "unrecognised argument: %s\n", argv[i]);
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(lvgl_port_init());
|
||||||
|
|
||||||
|
lv_display_t *disp = NULL;
|
||||||
|
|
||||||
|
if (png_path != NULL) {
|
||||||
|
disp = headless_display_create();
|
||||||
|
} else {
|
||||||
|
disp = lv_sdl_window_create(PANEL_H_RES, PANEL_V_RES);
|
||||||
|
if (disp != NULL) {
|
||||||
|
lv_sdl_window_set_title(disp, "thermostat-hw · CrowPanel 7\" 800x480");
|
||||||
|
lv_sdl_mouse_create(); /* mouse stands in for the GT911 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disp == NULL) {
|
||||||
|
fprintf(stderr, "%s: display creation failed\n", TAG);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_init();
|
||||||
|
ESP_ERROR_CHECK(lvgl_port_start());
|
||||||
|
ui_set_light_cb(sim_light_cmd);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A snapshot replaces the mock wholesale rather than layering onto it --
|
||||||
|
* mixing the two would show a screen that never occurs in reality.
|
||||||
|
*/
|
||||||
|
if (data_path != NULL) {
|
||||||
|
memset(&sim_data, 0, sizeof(sim_data));
|
||||||
|
if (!load_snapshot(data_path, false)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
apply_mock_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forecast_path != NULL && !load_snapshot(forecast_path, true)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_update(&sim_data);
|
||||||
|
ui_set_status(data_path != NULL ? data_path : "simulator - mock data");
|
||||||
|
|
||||||
|
if (png_path != NULL) {
|
||||||
|
/*
|
||||||
|
* Let timers and any entry animation settle before capturing,
|
||||||
|
* otherwise the screenshot catches a half-built frame.
|
||||||
|
*/
|
||||||
|
const uint32_t deadline_ms = lv_tick_get() + (uint32_t)settle_ms;
|
||||||
|
while (lv_tick_get() < deadline_ms) {
|
||||||
|
lv_timer_handler();
|
||||||
|
usleep(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_refr_now(disp); /* force a synchronous full render into headless_fb */
|
||||||
|
return headless_write_png(png_path) == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the
|
||||||
|
* process, so this loop does not need its own termination condition. */
|
||||||
|
while (true) {
|
||||||
|
lv_timer_handler();
|
||||||
|
usleep(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -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}]}
|
||||||
@@ -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}]}}}
|
||||||
@@ -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}]}
|
||||||
Executable
+115
@@ -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())
|
||||||
Executable
+110
@@ -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())
|
||||||
Reference in new issue
Block a user