New panel design

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

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ idf_component_register(
"ui/lvgl_port.c"
"ui/ui_manager.c"
"api/ambient_weather.c"
"api/ha_client.c"
"api/ha_data.c"
INCLUDE_DIRS
"."
"wifi"
+263
View File
@@ -0,0 +1,263 @@
/*
* Home Assistant REST transport. See ha_client.h for the contract and why
* this is plain HTTP.
*/
#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "app_config.h"
#include "ha_client.h"
#include "ha_template.h"
static const char *TAG = "ha_client";
#define HA_URL_MAX 128
#define HA_BODY_MAX 1024
/*
* The forecast reply is the big one: six days of conditions and temperatures,
* measured at ~1.1KB. The state template is ~400 bytes. 4KB leaves room for
* HA to grow a field without silently truncating.
*/
#define HA_RESP_MAX 4096
/* Local network, no TLS handshake, so these can be far tighter than the
* cloud client's. A LAN request that has not answered in 5s is not going to. */
#define HA_TIMEOUT_MS 5000
static char resp_buf[HA_RESP_MAX];
static size_t resp_len;
static bool resp_truncated;
/*
* Kept alive between polls. Re-creating the client each time re-opens the TCP
* connection, which is cheap on a LAN but not free, and this runs inside the
* window where rendering is paused.
*/
static esp_http_client_handle_t client;
/*
* Long-lived HA tokens are JWTs -- ~180-200 characters, not the 30-40 an
* "API key" suggests. 320 leaves room for "Bearer " plus a token noticeably
* longer than the ones HA issues today.
*/
static char auth_header[320];
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
switch (evt->event_id) {
case HTTP_EVENT_ON_DATA:
/* Responses are chunked, so Content-Length is often -1; accumulate
* into a fixed buffer rather than sizing an allocation from it. */
if (resp_len + evt->data_len < sizeof(resp_buf)) {
memcpy(resp_buf + resp_len, evt->data, evt->data_len);
resp_len += evt->data_len;
} else if (!resp_truncated) {
resp_truncated = true; /* once per response, not per chunk */
ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", HA_RESP_MAX);
}
break;
default:
break;
}
return ESP_OK;
}
static void client_drop(void)
{
if (client != NULL) {
esp_http_client_cleanup(client);
client = NULL;
}
}
esp_err_t ha_client_init(void)
{
if (HA_HOST[0] == '\0' || HA_TOKEN[0] == '\0') {
ESP_LOGW(TAG, "HA_HOST or HA_TOKEN not set in main/app_config.h -- "
"Home Assistant will not be polled");
return ESP_ERR_INVALID_STATE;
}
const int n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", HA_TOKEN);
if (n < 0 || n >= (int)sizeof(auth_header)) {
ESP_LOGE(TAG, "Token too long for the auth header buffer");
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
/*
* One POST. `path` and `body` are borrowed; the reply lands in resp_buf.
*
* The client is rebuilt whenever the URL changes, because the three endpoints
* this talks to (template, service call, forecast) are different paths on the
* same host and esp_http_client keeps the connection per-handle.
*/
static esp_err_t ha_post(const char *path, const char *body)
{
char url[HA_URL_MAX];
const int n = snprintf(url, sizeof(url), "http://%s:%d%s", HA_HOST, HA_PORT, path);
if (n < 0 || n >= (int)sizeof(url)) {
ESP_LOGE(TAG, "URL truncated for path %s", path);
return ESP_ERR_INVALID_SIZE;
}
if (client == NULL) {
const esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = HA_TIMEOUT_MS,
.event_handler = http_event_handler,
.keep_alive_enable = true,
};
client = esp_http_client_init(&config);
if (client == NULL) {
return ESP_ERR_NO_MEM;
}
esp_http_client_set_header(client, "Authorization", auth_header);
esp_http_client_set_header(client, "Content-Type", "application/json");
} else if (esp_http_client_set_url(client, url) != ESP_OK) {
client_drop();
return ESP_FAIL;
}
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_post_field(client, body, (int)strlen(body));
resp_len = 0;
resp_truncated = false;
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "POST %s failed: %s", path, esp_err_to_name(err));
client_drop(); /* a half-open connection would fail every later poll */
return err;
}
const int status = esp_http_client_get_status_code(client);
if (status != 200) {
ESP_LOGE(TAG, "HTTP %d from %s", status, path);
if (status == 401) {
ESP_LOGE(TAG, "Token rejected -- check HA_TOKEN in app_config.h");
}
client_drop();
return ESP_FAIL;
}
if (resp_len == 0) {
/* Service calls legitimately return an empty body. */
resp_buf[0] = '\0';
return ESP_OK;
}
resp_buf[resp_len] = '\0';
return ESP_OK;
}
esp_err_t ha_fetch_state(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/*
* The template is JSON-escaped by cJSON rather than by hand: it is full of
* quotes and braces, and getting the escaping subtly wrong yields a 400
* that looks like a template bug.
*/
cJSON *req = cJSON_CreateObject();
if (req == NULL) {
return ESP_ERR_NO_MEM;
}
if (cJSON_AddStringToObject(req, "template", HA_STATE_TEMPLATE) == NULL) {
cJSON_Delete(req);
return ESP_ERR_NO_MEM;
}
char *body = cJSON_PrintUnformatted(req);
cJSON_Delete(req);
if (body == NULL) {
return ESP_ERR_NO_MEM;
}
const esp_err_t err = ha_post("/api/template", body);
cJSON_free(body);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_json(resp_buf, data)) {
ESP_LOGE(TAG, "State response was not valid JSON");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_fetch_forecast(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* return_response is required: without it HA runs the service and returns
* nothing, which looks like an empty forecast rather than a mistake. */
const esp_err_t err = ha_post(
"/api/services/weather/get_forecasts?return_response", HA_FORECAST_BODY);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_forecast_json(resp_buf, data)) {
ESP_LOGE(TAG, "Forecast response could not be parsed");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_light_command(int index, bool on, int16_t brightness)
{
if (index < 0 || index >= HA_LIGHT_COUNT) {
return ESP_ERR_INVALID_ARG;
}
const ha_light_cfg_t *cfg = &HA_LIGHTS[index];
const char *domain = ha_service_domain(cfg->entity_id);
char path[HA_URL_MAX];
int n = snprintf(path, sizeof(path), "/api/services/%s/turn_%s",
domain, on ? "on" : "off");
if (n < 0 || n >= (int)sizeof(path)) {
return ESP_ERR_INVALID_SIZE;
}
char body[HA_BODY_MAX];
if (on && brightness >= 0 && cfg->dimmable) {
n = snprintf(body, sizeof(body),
"{\"entity_id\":\"%s\",\"brightness\":%d}",
cfg->entity_id, brightness);
} else {
/* No brightness key: turning on without one makes HA restore the
* light's previous level, which is what a user expects from a tap. */
n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\"}", cfg->entity_id);
}
if (n < 0 || n >= (int)sizeof(body)) {
return ESP_ERR_INVALID_SIZE;
}
ESP_LOGI(TAG, "%s -> %s%s", cfg->entity_id, on ? "on" : "off",
(on && brightness >= 0 && cfg->dimmable) ? " (with brightness)" : "");
return ha_post(path, body);
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Home Assistant REST transport (device only).
*
* Everything ESP-IDF-specific about talking to HA lives here; parsing and
* presentation are in ha_data.c, which the desktop simulator also compiles.
*
* Plain HTTP on the LAN, deliberately. The Ambient Weather cloud client needs
* TLS and pays a multi-second handshake for it; this talks to a box on the
* same switch, and the token is not worth a handshake inside the window where
* the UI is frozen. Do not point this at an https:// HA instance without
* revisiting that trade-off.
*/
#ifndef HA_CLIENT_H
#define HA_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#include "ha_data.h"
/* Validates configuration. Does not open a connection. */
esp_err_t ha_client_init(void);
/*
* One request for every sensor, light and sun time (~400 bytes). Leaves
* data->forecast alone -- see ha_fetch_forecast().
*
* Blocks for the round trip, so callers pause LVGL around it.
*/
esp_err_t ha_fetch_state(ha_data_t *data);
/*
* Fills data->forecast. A separate, slower poll: the forecast is a service
* call rather than a template render, and it changes hourly at most.
*/
esp_err_t ha_fetch_forecast(ha_data_t *data);
/*
* Turn a light on or off. `index` indexes HA_LIGHTS; `brightness` is 0-255,
* or -1 to leave the level alone (and always for switch-domain entities).
*
* Blocks. Must not be called from the LVGL task -- see ui_set_light_cb().
*/
esp_err_t ha_light_command(int index, bool on, int16_t brightness);
#endif
+76
View File
@@ -0,0 +1,76 @@
/*
* Home Assistant entity IDs for this installation.
*
* Entity IDs are not secrets, so unlike the token in app_config.h they are
* tracked. They are also not stable: renaming a device or reinstalling an
* integration changes them, and a wrong ID fails silently as "unavailable"
* rather than as an error. Re-check with tools/ha_discover.py after touching
* anything in HA.
*/
#ifndef HA_CONFIG_H
#define HA_CONFIG_H
/* --- Weather station (Ambient Weather, exposed locally through HA) ------- */
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
/*
* The anemometer has been offline since 2026-07-27: wind speed, gust and
* direction all read "unavailable". The display shows blanks for these by
* design -- no substituting forecast wind for station wind.
*
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
* it still reports its last value from 2026-07-28 and would render as a
* plausible live number. Anything using it needs a staleness check first.
*/
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
#define HA_ENT_WIND_DIR "sensor.moonlight_beach_wind_direction"
/* --- Forecast and sun ---------------------------------------------------- */
/* met.no via HA. Supplies the textual condition and the daily forecast. */
#define HA_ENT_WEATHER "weather.forecast_home"
#define HA_ENT_SUN "sun.sun"
/* --- Lights -------------------------------------------------------------- */
/*
* The X-macro is load-bearing, not decoration.
*
* The JSON template asks HA for these entities as an ordered array, and the
* firmware reads the reply back by index. Two hand-maintained lists would
* drift the first time one is reordered, and the failure mode is silent: the
* Dining Room tile would just start controlling the Blue Lamp. Generating the
* template's entity list and the C table from one source makes that
* impossible.
*
* X(entity_id, label, dimmable)
*
* `dimmable` is false for the patio, which is a TP-Link smart plug in the
* switch domain -- on/off only, and it needs switch.turn_on rather than
* light.turn_on. See ha_service_domain().
*
* Note light.main_room is a GROUP containing blue_lamp, brown_lamp and
* landing_chandelier. Toggling it changes three of the other tiles; that is
* expected, and the UI reflects it on the next poll.
*/
/* Labels must fit one line of Montserrat 16 in a ~125px tile -- roughly 11
* characters. Longer ones are truncated with an ellipsis, which reads as a
* mistake. "Dining Room" is one character too wide, hence "Dining". */
#define HA_LIGHT_LIST(X) \
X("light.main_room", "Main Room", true) \
X("light.dining_room", "Dining", true) \
X("light.blue_lamp", "Blue Lamp", true) \
X("light.brown_lamp", "Brown Lamp", true) \
X("light.landing_chandelier", "Chandelier", true) \
X("switch.tp_link_smart_plug_f048_outdoor_hanging_lights", "Patio", false)
#endif
+322
View File
@@ -0,0 +1,322 @@
/*
* Parsing and presentation helpers for Home Assistant data.
*
* Deliberately free of ESP-IDF and network dependencies: this file is
* compiled into the desktop simulator as well as the firmware, so the layout
* work in sim/ exercises the same parser the device runs. Keep it that way --
* anything needing esp_* belongs in ha_client.c.
*/
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "ha_data.h"
/* Build the config table from the same list the JSON template is built from. */
#define HA_LIGHT_ENTRY_X(id, label, dimmable) { id, label, dimmable },
const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = {
HA_LIGHT_LIST(HA_LIGHT_ENTRY_X)
};
/* ------------------------------------------------------------------ values */
/*
* A JSON null (or a missing key) means the sensor is unavailable and the
* field stays invalid. Note cJSON_IsNumber() is false for null, so this
* handles both cases without a separate null check.
*/
static void take_number(const cJSON *root, const char *key, ha_value_t *out)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsNumber(item)) {
out->value = (float)item->valuedouble;
out->valid = true;
}
}
static void take_string(const cJSON *root, const char *key, char *out, size_t len)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsString(item) && item->valuestring != NULL) {
snprintf(out, len, "%s", item->valuestring);
}
}
/* ------------------------------------------------------------------ lights */
static ha_light_state_t light_state_from_string(const char *s)
{
if (s == NULL) {
return HA_LIGHT_UNAVAILABLE;
}
if (strcmp(s, "on") == 0) {
return HA_LIGHT_ON;
}
if (strcmp(s, "off") == 0) {
return HA_LIGHT_OFF;
}
/* "unavailable", "unknown", or anything unexpected. Treated the same:
* the tile greys out rather than claiming the light is off. */
return HA_LIGHT_UNAVAILABLE;
}
static void parse_lights(const cJSON *root, ha_data_t *out)
{
const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights");
if (!cJSON_IsArray(lights)) {
return;
}
/*
* Index-aligned with HA_LIGHTS because the template iterates the same
* X-macro list. If HA returns a different count, something is out of sync
* between the template and the table -- take what lines up rather than
* reading past either array.
*/
const int n = cJSON_GetArraySize(lights);
const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT;
for (int i = 0; i < count; i++) {
const cJSON *entry = cJSON_GetArrayItem(lights, i);
if (!cJSON_IsObject(entry)) {
continue;
}
const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s");
out->lights[i].state = light_state_from_string(
cJSON_IsString(state) ? state->valuestring : NULL);
const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b");
out->lights[i].brightness = cJSON_IsNumber(bright)
? (int16_t)bright->valuedouble
: -1;
}
}
/* -------------------------------------------------------------- state JSON */
bool ha_parse_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
/*
* Preserve the forecast across the wipe. It arrives from a different,
* slower request; zeroing the whole struct here would blank the forecast
* strip on every sensor poll.
*/
ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS];
memcpy(saved_forecast, out->forecast, sizeof(saved_forecast));
const uint8_t saved_count = out->forecast_count;
memset(out, 0, sizeof(*out));
memcpy(out->forecast, saved_forecast, sizeof(saved_forecast));
out->forecast_count = saved_count;
take_number(root, "out_t", &out->outdoor_temp_f);
take_number(root, "out_h", &out->outdoor_humidity);
take_number(root, "in_t", &out->indoor_temp_f);
take_number(root, "in_h", &out->indoor_humidity);
take_number(root, "dew", &out->dew_point_f);
take_number(root, "press", &out->pressure_inhg);
take_number(root, "rain_d", &out->rain_today_in);
take_number(root, "rain_r", &out->rain_rate_inhr);
take_number(root, "wind", &out->wind_mph);
take_number(root, "gust", &out->gust_mph);
take_number(root, "wdir", &out->wind_dir_deg);
take_string(root, "cond", out->condition, sizeof(out->condition));
take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise));
take_string(root, "sunset", out->sunset, sizeof(out->sunset));
take_string(root, "clock", out->clock, sizeof(out->clock));
take_string(root, "date", out->date, sizeof(out->date));
out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up"));
parse_lights(root, out);
cJSON_Delete(root);
return true;
}
/* ----------------------------------------------------------- forecast JSON */
/*
* Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence.
*
* The device has no clock, so the day name cannot come from localtime(). The
* forecast timestamps are UTC; HA returns them at 19:00Z, which is midday
* locally, so the UTC date and the local date agree. That would stop being
* true for a timestamp near midnight UTC -- if HA ever changes the hour, this
* needs the offset applied first.
*/
static void weekday_name(const char *iso, char *out, size_t len)
{
int y = 0, m = 0, d = 0;
if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) {
snprintf(out, len, "--");
return;
}
/* Zeller treats January and February as months 13 and 14 of the year
* before. */
if (m < 3) {
m += 12;
y -= 1;
}
const int K = y % 100;
const int J = y / 100;
const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
/* Zeller's h: 0 = Saturday. */
static const char *const names[7] = {
"Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"
};
snprintf(out, len, "%s", names[h]);
}
bool ha_parse_forecast_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
bool ok = false;
/* { "service_response": { "<entity>": { "forecast": [ ... ] } } } */
const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response");
const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL;
const cJSON *list = cJSON_IsObject(entity)
? cJSON_GetObjectItemCaseSensitive(entity, "forecast")
: NULL;
if (cJSON_IsArray(list)) {
const int n = cJSON_GetArraySize(list);
const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS;
memset(out->forecast, 0, sizeof(out->forecast));
for (int i = 0; i < count; i++) {
const cJSON *day = cJSON_GetArrayItem(list, i);
if (!cJSON_IsObject(day)) {
continue;
}
ha_forecast_day_t *slot = &out->forecast[i];
const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime");
weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL,
slot->day, sizeof(slot->day));
take_string(day, "condition", slot->condition, sizeof(slot->condition));
take_number(day, "temperature", &slot->high_f);
take_number(day, "templow", &slot->low_f);
}
out->forecast_count = (uint8_t)count;
ok = true;
}
cJSON_Delete(root);
return ok;
}
/* ----------------------------------------------------------- presentation */
const char *ha_service_domain(const char *entity_id)
{
/* The patio plug lives in the switch domain; light.turn_on on a switch
* entity is accepted by HA and silently does nothing. */
if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) {
return "switch";
}
return "light";
}
typedef struct {
const char *slug;
const char *label;
const char *shrt;
} condition_name_t;
/* met.no condition slugs as HA reports them. `shrt` is for the forecast
* strip, where a column is about 150px wide. */
static const condition_name_t CONDITIONS[] = {
{ "clear-night", "Clear", "Clear" },
{ "cloudy", "Cloudy", "Cloudy" },
{ "exceptional", "Exceptional", "Severe" },
{ "fog", "Fog", "Fog" },
{ "hail", "Hail", "Hail" },
{ "lightning", "Thunderstorms", "Storm" },
{ "lightning-rainy", "Thunderstorms", "Storm" },
{ "partlycloudy", "Partly Cloudy", "Partly" },
{ "pouring", "Heavy Rain", "Heavy" },
{ "rainy", "Rain", "Rain" },
{ "snowy", "Snow", "Snow" },
{ "snowy-rainy", "Wintry Mix", "Wintry" },
{ "sunny", "Sunny", "Sunny" },
{ "windy", "Windy", "Windy" },
{ "windy-variant", "Windy", "Windy" },
};
static const condition_name_t *condition_lookup(const char *slug)
{
if (slug == NULL) {
return NULL;
}
for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) {
if (strcmp(CONDITIONS[i].slug, slug) == 0) {
return &CONDITIONS[i];
}
}
return NULL;
}
const char *ha_condition_label(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
/* Unrecognised slugs pass through rather than becoming "Unknown" -- if
* met.no adds a condition, seeing the raw slug on screen says what to
* add here. */
return (c != NULL) ? c->label : (slug != NULL ? slug : "--");
}
const char *ha_condition_short(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--");
}
const char *ha_wind_cardinal(float degrees)
{
static const char *const points[16] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
};
if (!isfinite(degrees)) {
return "--";
}
/* fmodf twice: a negative bearing would otherwise index out of bounds. */
float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f);
const int idx = (int)((d + 11.25f) / 22.5f) % 16;
return points[idx];
}
+136
View File
@@ -0,0 +1,136 @@
/*
* The data contract between "fetching" and "drawing".
*
* ha_data_t is plain data with no ESP-IDF or network dependency, and
* ha_parse_json() is a pure function. That is what lets the desktop simulator
* run the real UI: sim/ supplies a JSON snapshot or a LAN fetch, the firmware
* supplies esp_http_client, and both hand the identical struct to the
* identical drawing code.
*
* The UI layer must never see HTTP, cJSON or an entity ID -- only this.
*/
#ifndef HA_DATA_H
#define HA_DATA_H
#include <stdbool.h>
#include <stdint.h>
#include "ha_config.h"
/* Five days across the bottom strip. HA returns six; the sixth is dropped. */
#define HA_FORECAST_DAYS 5
/* Derived from HA_LIGHT_LIST so the two can never disagree. */
#define HA_LIGHT_COUNT_X(a, b, c) +1
#define HA_LIGHT_COUNT (0 HA_LIGHT_LIST(HA_LIGHT_COUNT_X))
/*
* A reading that may not exist.
*
* Sensors go unavailable routinely -- a dead anemometer, a flat battery, an
* integration reloading -- and a float alone cannot say so. Every consumer has
* to check `valid` before touching `value`; there is deliberately no sentinel
* to accidentally render as a real number.
*/
typedef struct {
float value;
bool valid;
} ha_value_t;
typedef enum {
HA_LIGHT_OFF = 0,
HA_LIGHT_ON,
HA_LIGHT_UNAVAILABLE, /* entity exists but HA cannot reach the device */
} ha_light_state_t;
typedef struct {
ha_light_state_t state;
/* 0-255 as HA reports it. Only meaningful when state is HA_LIGHT_ON and
* the entity is dimmable; -1 otherwise. */
int16_t brightness;
} ha_light_status_t;
typedef struct {
const char *entity_id;
const char *label;
bool dimmable;
} ha_light_cfg_t;
/* Generated from HA_LIGHT_LIST; index-aligned with ha_data_t.lights. */
extern const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT];
typedef struct {
char day[4]; /* "Mon" -- derived from the ISO date, see below */
char condition[24]; /* met.no condition slug, e.g. "partlycloudy" */
ha_value_t high_f;
ha_value_t low_f;
} ha_forecast_day_t;
typedef struct {
/* Weather station */
ha_value_t outdoor_temp_f;
ha_value_t outdoor_humidity;
ha_value_t indoor_temp_f;
ha_value_t indoor_humidity;
ha_value_t dew_point_f;
ha_value_t pressure_inhg;
ha_value_t rain_today_in;
ha_value_t rain_rate_inhr;
ha_value_t wind_mph;
ha_value_t gust_mph;
ha_value_t wind_dir_deg;
/* Forecast / sun. These strings arrive pre-formatted in local time --
* HA owns the timezone, so the device needs no clock and no SNTP. */
char condition[24];
char sunrise[12]; /* "6:05 AM" */
char sunset[12]; /* "7:45 PM" */
char clock[12]; /* "10:21 AM" */
char date[16]; /* "Tue Aug 4" */
bool sun_up;
ha_light_status_t lights[HA_LIGHT_COUNT];
ha_forecast_day_t forecast[HA_FORECAST_DAYS];
uint8_t forecast_count; /* 0 when no forecast has been fetched yet */
} ha_data_t;
/*
* Parse the reply to the state template into `out`.
*
* Zeroes `out` first, so a field missing from the response reads as invalid
* rather than keeping a previous poll's value. Returns false only when the
* body is not parseable JSON -- individual missing or unavailable sensors are
* normal and leave their ha_value_t invalid.
*
* Does not touch the forecast fields; those come from ha_parse_forecast_json()
* on a slower poll and would otherwise be wiped every minute.
*/
bool ha_parse_json(const char *json, ha_data_t *out);
/*
* Parse a weather/get_forecasts service response into out->forecast.
* Fills at most HA_FORECAST_DAYS entries and sets forecast_count.
*/
bool ha_parse_forecast_json(const char *json, ha_data_t *out);
/*
* "light" or "switch" for entity_id -- the HA service domain to call. The
* patio plug is a switch, and calling light.turn_on on it silently does
* nothing.
*/
const char *ha_service_domain(const char *entity_id);
/* Human-readable form of a met.no condition slug ("partlycloudy" ->
* "Partly Cloudy"). Returns the slug unchanged if unrecognised, so an
* unhandled condition shows up on screen as the thing to go add. */
const char *ha_condition_label(const char *slug);
/* As above, abbreviated for the forecast strip's narrow columns
* ("partlycloudy" -> "Partly"). */
const char *ha_condition_short(const char *slug);
/* Compass point for a bearing in degrees ("NNE"). */
const char *ha_wind_cardinal(float degrees);
#endif
+78
View File
@@ -0,0 +1,78 @@
/*
* The Jinja template POSTed to Home Assistant's /api/template.
*
* Why a template rather than plain REST calls:
*
* - One request instead of a dozen. /api/states returns all ~204 entities
* (far too much to buffer); fetching entities one at a time would mean a
* dozen round-trips inside the window where the UI is frozen. This is a
* single ~430 byte reply, measured at ~85ms on the LAN.
* - HA formats the times. sunrise/sunset/clock/date come back as local-time
* strings, so the device needs neither SNTP nor a timezone database, and
* DST is somebody else's problem. The board's clock starts at the epoch
* and that no longer matters.
* - Unavailable sensors arrive as JSON null, not a sentinel number. A
* sentinel would be indistinguishable from a real reading -- -1 mph of
* wind is impossible, but -1 degF of dew point is not.
*
* The reply is compact JSON; see ha_parse_json().
*/
#ifndef HA_TEMPLATE_H
#define HA_TEMPLATE_H
#include "ha_config.h"
/* Emits the entity list for the lights loop, in HA_LIGHT_LIST order. The
* trailing comma is deliberate -- Jinja accepts it, and it avoids a
* last-element special case. */
#define HA_TMPL_LIGHT_X(id, label, dimmable) "'" id "',"
/*
* num() -- a state as a number, or null if it is not one ("unavailable",
* "unknown", or a non-numeric state).
* attrnum() -- ditto for an attribute; brightness is None when a light is off.
*/
#define HA_STATE_TEMPLATE \
"{%- macro num(e) -%}{%- set v = states(e) -%}" \
"{{ v | float if is_number(v) else 'null' }}{%- endmacro -%}" \
"{%- macro attrnum(e, a) -%}{%- set v = state_attr(e, a) -%}" \
"{{ v if v is number else 'null' }}{%- endmacro -%}" \
"{" \
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
"\"rain_r\":" "{{ num('" HA_ENT_RAIN_RATE "') }}," \
"\"wind\":" "{{ num('" HA_ENT_WIND_SPEED "') }}," \
"\"gust\":" "{{ num('" HA_ENT_WIND_GUST "') }}," \
"\"wdir\":" "{{ num('" HA_ENT_WIND_DIR "') }}," \
"\"cond\":\"" "{{ states('" HA_ENT_WEATHER "') }}\"," \
"\"sunrise\":\"{{ (state_attr('" HA_ENT_SUN "','next_rising')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sunset\":\"{{ (state_attr('" HA_ENT_SUN "','next_setting')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sun_up\":{{ (states('" HA_ENT_SUN "') == 'above_horizon') | lower }}," \
"\"clock\":\"{{ now().strftime('%-I:%M %p') }}\"," \
"\"date\":\"{{ now().strftime('%a %b %-d') }}\"," \
"\"lights\":[" \
"{%- for e in [" HA_LIGHT_LIST(HA_TMPL_LIGHT_X) "] -%}" \
"{\"s\":\"{{ states(e) }}\",\"b\":{{ attrnum(e,'brightness') }}}" \
"{{ \",\" if not loop.last }}" \
"{%- endfor -%}" \
"]}"
/*
* Forecast body for POST /api/services/weather/get_forecasts?return_response.
*
* The forecast is deliberately NOT part of the template above: HA removed the
* `forecast` attribute from weather entities in 2024.4, and Jinja templates
* cannot call services. It also changes far more slowly than the sensors, so
* it is polled on its own longer interval.
*/
#define HA_FORECAST_BODY \
"{\"entity_id\":\"" HA_ENT_WEATHER "\",\"type\":\"daily\"}"
#endif
+30 -6
View File
@@ -5,15 +5,39 @@
#ifndef APP_CONFIG_H
#define APP_CONFIG_H
/* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
/* --- Home Assistant (primary data source) --------------------------------
*
* Plain HTTP on the LAN, so there is no certificate handling and no TLS
* handshake inside the window where rendering is paused. HA_TOKEN is a
* long-lived access token: in Home Assistant, click your user (bottom left)
* -> Security -> Long-lived access tokens -> Create token.
*
* Entity IDs are NOT here -- they are not secrets, so they live in
* main/api/ha_config.h. Re-check them with tools/ha_discover.py.
*/
#define HA_HOST "192.168.1.134"
#define HA_PORT 8123
#define HA_TOKEN ""
#define HA_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* --- Ambient Weather (fallback only) -------------------------------------
*
* Used only when Home Assistant cannot be reached, and then only for outdoor
* temperature, humidity and wind -- indoor, sun, forecast and lights have no
* cloud equivalent. Leaving these blank is fine; it just means no fallback.
*
* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF"
*/
#define AMBIENT_WEATHER_API_KEY ""
#define AMBIENT_WEATHER_APP_KEY ""
#define AMBIENT_WEATHER_DEVICE_ID ""
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* The fallback client's own poll interval name. */
#define WEATHER_UPDATE_INTERVAL_MS HA_UPDATE_INTERVAL_MS
#endif
+160 -56
View File
@@ -4,12 +4,15 @@
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "ambient_weather.h"
#include "app_config.h"
#include "backlight.h"
#include "display_rgb.h"
#include "ha_client.h"
#include "ha_data.h"
#include "i2c_bus.h"
#include "lvgl_port.h"
#include "touch_gt911.h"
@@ -19,99 +22,187 @@
static const char *TAG = "main";
/*
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
* is why the handshake fits at all. Re-check the high-water-mark log if more
* work lands in this task.
* 6144 was measured with the old cloud client, whose TLS handshake was the
* deepest this task ever got. The HA path is plain HTTP and shallower, but
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
* headroom is kept. Re-check the high-water-mark log if more work lands here.
*/
#define WEATHER_TASK_STACK 6144
#define WEATHER_TASK_PRIO 3
#define WEATHER_FIRST_DELAY_MS 3000
#define HA_TASK_STACK 6144
#define HA_TASK_PRIO 3
#define HA_FIRST_DELAY_MS 3000
/* The forecast changes hourly at most and costs a service call, so it is
* polled far less often than the sensors. */
#define FORECAST_EVERY_N_POLLS 30
/* Consecutive failed polls before the displayed reading is treated as stale. */
#define WEATHER_STALE_AFTER_POLLS 2
#define STALE_AFTER_POLLS 2
static void weather_update_task(void *arg)
/* Depth 8: a burst of taps while the network is slow should queue rather than
* be dropped, but a user cannot meaningfully get more than a few ahead. */
#define CMD_QUEUE_DEPTH 8
typedef struct {
int index;
bool on;
int16_t brightness;
} light_cmd_t;
static QueueHandle_t cmd_queue;
/* The single copy of what is on screen. Only the HA task touches it. */
static ha_data_t ha_data;
/*
* Runs on the LVGL task with the LVGL lock held, so it must not block.
* Queueing hands the HTTP request to the HA task; a request from here would
* freeze rendering for the duration and deadlock anything waiting on the lock.
*/
static void light_cmd_from_ui(int index, bool on, int16_t brightness)
{
const light_cmd_t cmd = { .index = index, .on = on, .brightness = brightness };
if (cmd_queue == NULL || xQueueSend(cmd_queue, &cmd, 0) != pdTRUE) {
ESP_LOGW(TAG, "Light command queue full, dropping command for index %d", index);
}
}
/*
* Fall back to the Ambient Weather cloud API when HA is unreachable.
*
* Only fills the outdoor readings -- indoor, sun, lights and forecast have no
* cloud equivalent and stay as they were. That is deliberate: a partial screen
* of live outdoor data beats a blank one, and the status line says the panel
* is running on the fallback.
*/
static bool fill_from_cloud(ha_data_t *data)
{
weather_data_t w;
if (weather_update(&w) != ESP_OK) {
return false;
}
data->outdoor_temp_f = (ha_value_t){ w.temp_f, w.has_temp };
data->outdoor_humidity = (ha_value_t){ w.humidity, w.has_humidity };
data->wind_mph = (ha_value_t){ w.wind_mph, w.has_wind };
return w.has_temp;
}
static void ha_task(void *arg)
{
weather_data_t weather;
char line[96];
/*
* Age is tracked in ticks, not from weather_data_t.last_update. That field
* comes from time(NULL), and with no SNTP client the system clock starts
* at the epoch -- an age derived from it would be nonsense. Tick
* Age is tracked in ticks, not wall time: with no SNTP the system clock
* starts at the epoch. (The clock shown on screen comes from HA, which is
* why the panel can display the time without knowing it.) Tick
* subtraction is also wrap-safe.
*/
TickType_t last_success = 0;
int consecutive_failures = 0;
bool have_reading = false;
bool stack_logged = false;
uint32_t poll_count = 0;
vTaskDelay(pdMS_TO_TICKS(WEATHER_FIRST_DELAY_MS));
vTaskDelay(pdMS_TO_TICKS(HA_FIRST_DELAY_MS));
/*
* Zero on the first pass so the dashboard populates immediately after
* boot. The wait below is what paces the loop, and it sits at the top --
* so a full-interval timeout here would leave the panel showing dashes
* for HA_UPDATE_INTERVAL_MS before the first poll ever ran. Measured on
* hardware as a 63-second blank screen.
*/
TickType_t wait_ticks = 0;
while (1) {
/*
* Wait for a light command, or time out and poll. A command wakes the
* task immediately so a tap feels instant instead of waiting out the
* poll interval.
*/
light_cmd_t cmd;
const bool have_cmd = (cmd_queue != NULL) &&
(xQueueReceive(cmd_queue, &cmd, wait_ticks) == pdTRUE);
wait_ticks = pdMS_TO_TICKS(HA_UPDATE_INTERVAL_MS);
if (!wifi_is_connected()) {
ui_set_status("WiFi: reconnecting...");
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
continue;
}
/*
* Freeze rendering for the duration of the fetch. The TLS session,
* the LVGL redraw and the flush into the PSRAM framebuffer all
* compete with the LCD's own DMA out of that same PSRAM; letting them
* overlap is what produces visible artefacts. The panel keeps
* scanning the last completed frame, so the screen simply holds still.
* Freeze rendering for the duration of the network work. The redraw
* and the flush into the PSRAM framebuffer compete with the LCD's own
* DMA out of that same PSRAM; letting them overlap is what produces
* visible artefacts. The panel keeps scanning the last completed
* frame, so the screen simply holds still.
*/
lvgl_port_pause();
const esp_err_t err = weather_update(&weather);
if (have_cmd) {
const esp_err_t cmd_err = ha_light_command(cmd.index, cmd.on, cmd.brightness);
if (cmd_err != ESP_OK) {
ESP_LOGW(TAG, "Light command failed: %s", esp_err_to_name(cmd_err));
}
}
esp_err_t err = ha_fetch_state(&ha_data);
/* The forecast is a second request, so only fetch it when the first
* one proved HA is reachable. */
if (err == ESP_OK &&
(ha_data.forecast_count == 0 || (poll_count % FORECAST_EVERY_N_POLLS) == 0)) {
if (ha_fetch_forecast(&ha_data) != ESP_OK) {
ESP_LOGW(TAG, "Forecast fetch failed; keeping previous days");
}
}
bool on_fallback = false;
if (err != ESP_OK) {
on_fallback = fill_from_cloud(&ha_data);
}
lvgl_port_resume();
if (err == ESP_OK) {
poll_count++;
if (err == ESP_OK || on_fallback) {
last_success = xTaskGetTickCount();
consecutive_failures = 0;
have_reading = true;
weather_get_string(&weather, line, sizeof(line));
ESP_LOGI(TAG, "Weather: %s", line);
ui_update(&ha_data);
ui_set_stale(false);
char detail[64];
snprintf(detail, sizeof(detail), "%.0f%% humidity",
weather.has_humidity ? weather.humidity : 0.0f);
if (on_fallback) {
ui_set_status("Home Assistant unreachable - cloud weather only");
} else {
wifi_get_status(line, sizeof(line));
ui_set_status(line);
}
/* lroundf, not +0.5f-and-truncate: the latter rounds toward zero
* below 0°F, turning -10.6 into -10. */
ui_update_weather((int16_t)lroundf(weather.temp_f),
weather.has_humidity ? detail : "Outdoor");
ui_set_weather_stale(false);
wifi_get_status(line, sizeof(line));
ui_set_status(line);
/* One-shot, after a fetch that included a TLS handshake -- that is
* the deepest this task's stack ever gets. */
if (!stack_logged) {
stack_logged = true;
ESP_LOGI(TAG, "weather task stack headroom: %u bytes",
ESP_LOGI(TAG, "HA task stack headroom: %u bytes",
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
}
} else {
consecutive_failures++;
ESP_LOGW(TAG, "Weather update failed (%d consecutive)", consecutive_failures);
ESP_LOGW(TAG, "Update failed (%d consecutive)", consecutive_failures);
if (have_reading && consecutive_failures >= WEATHER_STALE_AFTER_POLLS) {
if (have_reading && consecutive_failures >= STALE_AFTER_POLLS) {
const uint32_t age_min =
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
ui_set_weather_stale(true);
ui_set_stale(true);
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
ui_set_status(line);
} else {
ui_set_status("Weather update failed");
ui_set_status("Update failed");
}
}
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
}
}
@@ -130,7 +221,7 @@ void app_main(void)
*/
const esp_err_t wifi_err = wifi_init_sta();
if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "WiFi not up yet -- weather starts when it connects");
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
}
/* LVGL core must exist before the display and touch drivers register
@@ -150,7 +241,8 @@ void app_main(void)
ESP_ERROR_CHECK(display_init());
if (touch_init() != ESP_OK) {
/* A dead touch panel should not stop the display from working. */
/* A dead touch panel should not stop the display from working -- but
* it does mean the light tiles are unusable. */
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
}
@@ -166,24 +258,36 @@ void app_main(void)
wifi_get_status(status, sizeof(status));
ui_set_status(status);
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
/* The event handler keeps retrying in the background; the weather
* task polls wifi_is_connected(), so start it anyway rather than
* permanently disabling weather because boot raced a slow AP. */
/* The event handler keeps retrying in the background; the HA task
* polls wifi_is_connected(), so start it anyway rather than
* permanently disabling updates because boot raced a slow AP. */
ui_set_status("WiFi: connecting...");
} else {
/* No SSID configured (or setup failed outright) -- nothing will ever
* connect, so there is no point starting the weather task. */
* connect, so there is no point starting the task. */
ui_set_status("WiFi: not configured");
return;
}
if (weather_init() != ESP_OK) {
ui_set_status("Weather: no API credentials");
if (ha_client_init() != ESP_OK) {
ui_set_status("Home Assistant: not configured");
return;
}
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
WEATHER_TASK_PRIO, NULL);
/* Optional: only used when HA cannot be reached. A missing cloud API key
* is not fatal, it just means there is no fallback. */
if (weather_init() != ESP_OK) {
ESP_LOGW(TAG, "Ambient Weather fallback unavailable (no credentials)");
}
cmd_queue = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(light_cmd_t));
if (cmd_queue == NULL) {
ESP_LOGE(TAG, "Could not create the light command queue");
return;
}
ui_set_light_cb(light_cmd_from_ui);
xTaskCreate(ha_task, "ha", HA_TASK_STACK, NULL, HA_TASK_PRIO, NULL);
ESP_LOGI(TAG, "System ready");
}
+602 -96
View File
@@ -1,3 +1,22 @@
/*
* Dashboard layout for the 800x480 panel.
*
* Five horizontal bands, sized to fill exactly one screen with no scrolling:
*
* header 42px date, clock, current condition
* body 182px outdoor temperature card + a 3x2 grid of readings
* lights 116px one tile per HA_LIGHTS entry
* forecast 86px five day columns
* status 14px WiFi / poll failures
*
* Nothing is hidden behind navigation: this is a wall panel that has to be
* readable from across the room at a glance.
*
* Compiled into the desktop simulator as well as the firmware, so no driver,
* socket or ESP-IDF calls belong here. See sim/README.md.
*/
#include <math.h>
#include <stdio.h>
#include <string.h>
@@ -12,24 +31,101 @@ static const char *TAG = "ui_manager";
#define UI_LOCK_TIMEOUT_MS 1000
/*
* Bring-up diagnostic: a live readout of the last touch coordinate. Off by
* default -- define UI_DEBUG_TOUCH to put it back on screen when working on
* the input path.
* Band heights.
*
* The screen is 480px with 8px of padding top and bottom, so the content area
* is 464px. Five bands separated by four gaps must fit that exactly:
*
* 42 + 182 + 116 + 86 + 14 = 440 bands
* + 4 * 6 = 24 gaps
* 464 = 480 - 2*SCREEN_PAD
*
* Overrunning does not clip visibly -- flex silently pushes the last band off
* the bottom, which is how the status line vanished the first time. If you
* change one of these, change another to compensate and keep the sum at 464.
*/
/* #define UI_DEBUG_TOUCH */
#define SCREEN_PAD 8
#define BAND_HEADER_H 42
#define BAND_BODY_H 182
#define BAND_LIGHTS_H 116
#define BAND_FORECAST_H 86
#define BAND_STATUS_H 14
#define BAND_GAP 6
static lv_obj_t *temp_label = NULL;
static lv_obj_t *cond_label = NULL;
static lv_obj_t *status_label = NULL;
#ifdef UI_DEBUG_TOUCH
static lv_obj_t *touch_label = NULL;
#endif
/* Palette. Kept together so a retheme is one edit, not thirty. */
#define COL_BG 0x131C28
#define COL_PANEL 0x1E2A3A
#define COL_PANEL_HI 0x27384D
#define COL_ACCENT 0x3498DB
#define COL_ACCENT_WARM 0xE8A33D
#define COL_TEXT 0xFFFFFF
#define COL_TEXT_DIM 0x8A99AB
#define COL_TEXT_FAINT 0x5A6672
#define COL_ON 0xF5C542
#define COL_STALE 0x5A6672
#define COL_TRACK 0x0C131C /* slider groove; must read as a groove
against COL_PANEL, not blend into it */
/*
* lv_label_set_text() invalidates the label unconditionally, even when the
* new text is identical -- which would repaint the temperature every poll
* whether or not it moved. Comparing first means an unchanged value costs
* nothing: no invalidation, so no redraw and no flush.
* A reading with no value renders as a dash rather than 0 or a blank. Blank
* reads as "this panel is broken"; 0 reads as a measurement. Neither is true
* when a sensor is unavailable -- which for the wind sensors is the normal
* state, see ha_config.h.
*
* ASCII hyphens, not an em dash. LVGL's Montserrat builds cover Latin-1 plus
* a few symbols; U+2014 is not among them and renders as a tofu box. The
* degree sign is fine, U+00B7 and U+2014 are not.
*/
#define NO_VALUE "--"
static lv_obj_t *clock_label;
static lv_obj_t *date_label;
static lv_obj_t *cond_label;
static lv_obj_t *temp_label;
static lv_obj_t *temp_unit_label;
static lv_obj_t *outdoor_detail_label;
/* 3x2 reading grid: value and caption per cell. */
enum {
CELL_INDOOR = 0,
CELL_WIND,
CELL_RAIN,
CELL_SUNRISE,
CELL_SUNSET,
CELL_PRESSURE,
CELL_COUNT,
};
static lv_obj_t *cell_value[CELL_COUNT];
static lv_obj_t *cell_sub[CELL_COUNT];
static lv_obj_t *light_tile[HA_LIGHT_COUNT];
static lv_obj_t *light_name[HA_LIGHT_COUNT];
static lv_obj_t *light_state[HA_LIGHT_COUNT];
static lv_obj_t *light_slider[HA_LIGHT_COUNT];
static lv_obj_t *fc_day[HA_FORECAST_DAYS];
static lv_obj_t *fc_cond[HA_FORECAST_DAYS];
static lv_obj_t *fc_temp[HA_FORECAST_DAYS];
static lv_obj_t *status_label;
static ui_light_cmd_cb_t light_cb;
/*
* Last state pushed to the widgets, so ui_update() can skip a light tile whose
* state and brightness are unchanged. Without this, every poll would restyle
* six tiles and move six sliders.
*/
static ha_light_status_t last_light[HA_LIGHT_COUNT];
static bool last_light_valid;
/* ------------------------------------------------------------- primitives */
/*
* lv_label_set_text() invalidates unconditionally, even when the new text is
* identical -- which would repaint the whole dashboard every poll whether or
* not anything moved. Comparing first means an unchanged value costs nothing.
*/
static void label_set_if_changed(lv_obj_t *label, const char *text)
{
@@ -45,52 +141,489 @@ static void label_set_if_changed(lv_obj_t *label, const char *text)
lv_label_set_text(label, text);
}
/*
* NOTE, even with the readout compiled out: LVGL does not bubble events to
* parents by default. An event handler on the screen object never fires for
* taps landing on a child widget -- the header or the temperature card
* consume them -- which presents as whole regions of the panel being dead to
* touch while the hardware reports them perfectly well. Anything that needs
* to observe touches screen-wide must poll the input device, as below, rather
* than hang an event handler on the screen.
*/
#ifdef UI_DEBUG_TOUCH
static void touch_readout_timer_cb(lv_timer_t *timer)
/* Formats a reading, or NO_VALUE when the sensor had nothing to say. */
static void format_value(const ha_value_t *v, const char *fmt, char *buf, size_t len)
{
lv_indev_t *indev = lv_indev_get_next(NULL);
if (indev == NULL || touch_label == NULL) {
return;
}
lv_point_t p;
lv_indev_get_point(indev, &p);
char buf[48];
if (lv_indev_get_state(indev) == LV_INDEV_STATE_PRESSED) {
snprintf(buf, sizeof(buf), "touch %d, %d", (int)p.x, (int)p.y);
if (v->valid) {
snprintf(buf, len, fmt, v->value);
} else {
snprintf(buf, sizeof(buf), "touch %d, %d (released)", (int)p.x, (int)p.y);
snprintf(buf, len, "%s", NO_VALUE);
}
label_set_if_changed(touch_label, buf);
}
#endif
void ui_update_weather(int16_t temp_f, const char *condition)
static lv_obj_t *panel_create(lv_obj_t *parent, int32_t w, int32_t h, uint32_t colour)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped weather update: LVGL lock timed out");
lv_obj_t *p = lv_obj_create(parent);
lv_obj_set_size(p, w, h);
lv_obj_set_style_bg_color(p, lv_color_hex(colour), LV_PART_MAIN);
lv_obj_set_style_border_width(p, 0, LV_PART_MAIN);
lv_obj_set_style_radius(p, 10, LV_PART_MAIN);
lv_obj_set_style_pad_all(p, 8, LV_PART_MAIN);
lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE);
return p;
}
static lv_obj_t *label_create(lv_obj_t *parent, const char *text,
const lv_font_t *font, uint32_t colour)
{
lv_obj_t *l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(colour), LV_PART_MAIN);
return l;
}
/* ------------------------------------------------------------ light tiles */
/*
* Both handlers run on the LVGL task with the lock held. They only forward to
* light_cb, which must not block -- see ui_manager.h.
*/
static void light_tile_clicked(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
char buf[32];
snprintf(buf, sizeof(buf), "%d°F", temp_f);
/* Unavailable entities are not toggleable; the tile is styled to show it,
* but a stray tap should still do nothing rather than send a doomed
* command. */
if (last_light_valid && last_light[idx].state == HA_LIGHT_UNAVAILABLE) {
return;
}
const bool turn_on = !(last_light_valid && last_light[idx].state == HA_LIGHT_ON);
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
* the current brightness here would fight a user who dimmed the light
* elsewhere. */
light_cb(idx, turn_on, -1);
}
static void light_slider_changed(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
lv_obj_t *slider = lv_event_get_target_obj(e);
const int32_t pct = lv_slider_get_value(slider);
/* Dragging to zero means off, not "on at zero brightness" -- HA accepts
* brightness 0 but leaves the light on, which looks like a bug. */
if (pct <= 0) {
light_cb(idx, false, -1);
return;
}
light_cb(idx, true, (int16_t)((pct * 255) / 100));
}
static void light_tile_build(lv_obj_t *parent, int idx, int32_t w, int32_t h)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
lv_obj_t *tile = panel_create(parent, w, h, COL_PANEL);
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tile, light_tile_clicked, LV_EVENT_CLICKED,
(void *)(intptr_t)idx);
lv_obj_set_style_pad_all(tile, 10, LV_PART_MAIN);
light_tile[idx] = tile;
/*
* Explicit alignment rather than flex. A flex column packs the three
* children against the top and leaves the rest of a 116px tile empty; the
* slider wants to sit at the bottom where a thumb reaches it.
*/
light_name[idx] = label_create(tile, cfg->label, &lv_font_montserrat_16, COL_TEXT);
lv_label_set_long_mode(light_name[idx], LV_LABEL_LONG_DOT);
lv_obj_set_width(light_name[idx], LV_PCT(100));
/*
* An explicit one-line height is what actually forces truncation. With a
* content-sized height LV_LABEL_LONG_DOT has room to grow, so it wraps to
* a second line first and only then adds dots -- and that second line
* lands on top of the state label below.
*/
lv_obj_set_height(light_name[idx], lv_font_get_line_height(&lv_font_montserrat_16));
lv_obj_align(light_name[idx], LV_ALIGN_TOP_LEFT, 0, 0);
light_state[idx] = label_create(tile, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_align(light_state[idx], LV_ALIGN_TOP_LEFT, 0, 24);
if (cfg->dimmable) {
lv_obj_t *s = lv_slider_create(tile);
lv_obj_set_width(s, LV_PCT(100));
lv_obj_set_height(s, 12);
lv_obj_align(s, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_slider_set_range(s, 0, 100);
/*
* The track needs real contrast against the tile. COL_PANEL_HI is
* only a shade off the tile background, which made an off light's
* slider look like a lone floating knob.
*/
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TRACK), LV_PART_MAIN);
lv_obj_set_style_bg_color(s, lv_color_hex(COL_ON), LV_PART_INDICATOR);
/* Dim knob, not white. A white knob on an off tile is the highest
* contrast thing in the row, so six dark tiles read as six bright
* dots -- the eye goes straight to the controls that are doing
* nothing. */
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TEXT_DIM), LV_PART_KNOB);
lv_obj_set_style_pad_all(s, 2, LV_PART_KNOB);
/* RELEASED, not VALUE_CHANGED: the latter fires continuously while a
* finger drags, which would queue a command per pixel. */
lv_obj_add_event_cb(s, light_slider_changed, LV_EVENT_RELEASED,
(void *)(intptr_t)idx);
light_slider[idx] = s;
} else {
/* The patio is a smart plug in the switch domain -- no brightness at
* all, so its tile gets a label where the slider would be rather than
* a dead gap that looks like a missing control. */
lv_obj_t *note = label_create(tile, "switch", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_align(note, LV_ALIGN_BOTTOM_LEFT, 0, 0);
light_slider[idx] = NULL;
}
}
static void light_tile_update(int idx, const ha_light_status_t *st)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
const bool on = (st->state == HA_LIGHT_ON);
const bool dead = (st->state == HA_LIGHT_UNAVAILABLE);
lv_obj_set_style_bg_color(light_tile[idx],
lv_color_hex(on ? COL_PANEL_HI : COL_PANEL),
LV_PART_MAIN);
lv_obj_set_style_border_width(light_tile[idx], on ? 2 : 0, LV_PART_MAIN);
lv_obj_set_style_border_color(light_tile[idx], lv_color_hex(COL_ON), LV_PART_MAIN);
lv_obj_set_style_text_color(light_name[idx],
lv_color_hex(dead ? COL_TEXT_FAINT : COL_TEXT),
LV_PART_MAIN);
char buf[24];
if (dead) {
snprintf(buf, sizeof(buf), "unavailable");
} else if (!on) {
snprintf(buf, sizeof(buf), "Off");
} else if (cfg->dimmable && st->brightness >= 0) {
/* HA's 0-255 shown as a percentage; +127 rounds to nearest so 255
* reads as 100%% and not 99%%. */
snprintf(buf, sizeof(buf), "On %d%%", (st->brightness * 100 + 127) / 255);
} else {
snprintf(buf, sizeof(buf), "On");
}
label_set_if_changed(light_state[idx], buf);
lv_obj_set_style_text_color(light_state[idx],
lv_color_hex(on ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
if (light_slider[idx] != NULL) {
const int32_t pct = (on && st->brightness >= 0)
? (st->brightness * 100 + 127) / 255
: 0;
lv_slider_set_value(light_slider[idx], pct, LV_ANIM_OFF);
/* Bright knob only while the light is on, so the lit tiles are what
* draw the eye along the row. */
lv_obj_set_style_bg_color(light_slider[idx],
lv_color_hex(on ? COL_TEXT : COL_TEXT_DIM),
LV_PART_KNOB);
if (dead) {
lv_obj_add_state(light_slider[idx], LV_STATE_DISABLED);
} else {
lv_obj_remove_state(light_slider[idx], LV_STATE_DISABLED);
}
}
}
/* ------------------------------------------------------------------ build */
static void build_header(lv_obj_t *scr)
{
lv_obj_t *bar = panel_create(scr, LV_PCT(100), BAND_HEADER_H, COL_PANEL);
lv_obj_set_style_radius(bar, 0, LV_PART_MAIN);
lv_obj_set_style_pad_hor(bar, 16, LV_PART_MAIN);
date_label = label_create(bar, "--", &lv_font_montserrat_20, COL_TEXT_DIM);
lv_obj_align(date_label, LV_ALIGN_LEFT_MID, 0, 0);
clock_label = label_create(bar, "--:--", &lv_font_montserrat_24, COL_TEXT);
lv_obj_align(clock_label, LV_ALIGN_CENTER, 0, 0);
cond_label = label_create(bar, "--", &lv_font_montserrat_20, COL_ACCENT);
lv_obj_align(cond_label, LV_ALIGN_RIGHT_MID, 0, 0);
}
static void build_body(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_BODY_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
/* --- outdoor card --- */
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL);
lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN);
lv_obj_t *heading = label_create(card, "OUTDOOR", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
/* Centred vertically rather than pinned under the heading: the card is
* 182px and a 48px number at the top leaves an obvious hole in the middle
* of the most important element on the screen. */
temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT);
lv_obj_align(temp_label, LV_ALIGN_LEFT_MID, 0, 0);
/* Degree sign as its own label so it can sit small and high beside a
* 48px number instead of scaling with it. */
temp_unit_label = label_create(card, "°F", &lv_font_montserrat_20, COL_TEXT_DIM);
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
outdoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM);
lv_obj_align(outdoor_detail_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
/* --- 3x2 reading grid --- */
lv_obj_t *grid = lv_obj_create(row);
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
lv_obj_set_flex_grow(grid, 1);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(grid, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(grid, 0, LV_PART_MAIN);
lv_obj_set_style_pad_gap(grid, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
static const char *const captions[CELL_COUNT] = {
"INDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE",
};
/* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the
* wrap puts a third row below the fold. */
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
for (int i = 0; i < CELL_COUNT; i++) {
lv_obj_t *cell = panel_create(grid, 156, cell_h, COL_PANEL);
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
lv_obj_align(cap, LV_ALIGN_TOP_LEFT, 0, 0);
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_24, COL_TEXT);
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
cell_sub[i] = label_create(cell, "", &lv_font_montserrat_12, COL_TEXT_DIM);
lv_obj_align(cell_sub[i], LV_ALIGN_BOTTOM_LEFT, 0, 0);
}
}
static void build_lights(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_LIGHTS_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
const int32_t tile_w = (800 - 2 * SCREEN_PAD - (HA_LIGHT_COUNT - 1) * BAND_GAP)
/ HA_LIGHT_COUNT;
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
light_tile_build(row, i, tile_w, LV_PCT(100));
}
}
static void build_forecast(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_FORECAST_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
const int32_t col_w = (800 - 2 * SCREEN_PAD - (HA_FORECAST_DAYS - 1) * BAND_GAP)
/ HA_FORECAST_DAYS;
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
lv_obj_t *col = panel_create(row, col_w, LV_PCT(100), COL_PANEL);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(col, 2, LV_PART_MAIN);
fc_day[i] = label_create(col, "--", &lv_font_montserrat_16, COL_TEXT);
fc_cond[i] = label_create(col, "--", &lv_font_montserrat_12, COL_TEXT_DIM);
fc_temp[i] = label_create(col, NO_VALUE, &lv_font_montserrat_16, COL_ACCENT);
}
}
/* ----------------------------------------------------------------- update */
static void update_body(const ha_data_t *d)
{
char buf[48];
char sub[48];
/* Outdoor card. Rounded with lroundf, not truncation: below 0 degF a cast
* rounds toward zero and turns -10.6 into -10. */
if (d->outdoor_temp_f.valid) {
snprintf(buf, sizeof(buf), "%d", (int)lroundf(d->outdoor_temp_f.value));
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(temp_label, buf);
label_set_if_changed(cond_label, condition);
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
snprintf(buf, sizeof(buf), "%.0f%% humidity dew %.0f°",
d->outdoor_humidity.value, d->dew_point_f.value);
} else if (d->outdoor_humidity.valid) {
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->outdoor_humidity.value);
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(outdoor_detail_label, buf);
/* Indoor */
format_value(&d->indoor_temp_f, "%.1f°", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_INDOOR], buf);
if (d->indoor_humidity.valid) {
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->indoor_humidity.value);
} else {
sub[0] = '\0';
}
label_set_if_changed(cell_sub[CELL_INDOOR], sub);
/*
* Wind. The station anemometer has been offline since 2026-07-27, so this
* cell is expected to read "--" until the hardware is fixed. It is
* deliberately NOT backfilled from the met.no forecast: showing regional
* modelled wind under a heading that means "my yard" is worse than
* showing nothing.
*/
format_value(&d->wind_mph, "%.0f", buf, sizeof(buf));
if (d->wind_mph.valid) {
const size_t n = strlen(buf);
snprintf(buf + n, sizeof(buf) - n, " mph");
}
label_set_if_changed(cell_value[CELL_WIND], buf);
if (d->wind_dir_deg.valid && d->gust_mph.valid) {
snprintf(sub, sizeof(sub), "%s gust %.0f",
ha_wind_cardinal(d->wind_dir_deg.value), d->gust_mph.value);
} else if (d->wind_dir_deg.valid) {
snprintf(sub, sizeof(sub), "%s", ha_wind_cardinal(d->wind_dir_deg.value));
} else {
snprintf(sub, sizeof(sub), "sensor offline");
}
label_set_if_changed(cell_sub[CELL_WIND], sub);
/* Rain */
format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_RAIN], buf);
if (d->rain_rate_inhr.valid && d->rain_rate_inhr.value > 0.0f) {
snprintf(sub, sizeof(sub), "%.2f in/h now", d->rain_rate_inhr.value);
} else {
snprintf(sub, sizeof(sub), "not raining");
}
label_set_if_changed(cell_sub[CELL_RAIN], sub);
/* Sun. These strings are already local time from HA -- the board has no
* clock of its own. */
label_set_if_changed(cell_value[CELL_SUNRISE],
d->sunrise[0] ? d->sunrise : NO_VALUE);
label_set_if_changed(cell_sub[CELL_SUNRISE], d->sun_up ? "up now" : "before dawn");
label_set_if_changed(cell_value[CELL_SUNSET],
d->sunset[0] ? d->sunset : NO_VALUE);
label_set_if_changed(cell_sub[CELL_SUNSET], d->sun_up ? "still up" : "down");
/* Pressure */
format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf));
label_set_if_changed(cell_value[CELL_PRESSURE], buf);
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
}
static void update_forecast(const ha_data_t *d)
{
char buf[32];
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
if (i >= d->forecast_count) {
label_set_if_changed(fc_day[i], "--");
label_set_if_changed(fc_cond[i], "");
label_set_if_changed(fc_temp[i], NO_VALUE);
continue;
}
const ha_forecast_day_t *f = &d->forecast[i];
label_set_if_changed(fc_day[i], f->day);
label_set_if_changed(fc_cond[i], ha_condition_short(f->condition));
if (f->high_f.valid && f->low_f.valid) {
snprintf(buf, sizeof(buf), "%d / %d",
(int)lroundf(f->high_f.value), (int)lroundf(f->low_f.value));
} else if (f->high_f.valid) {
snprintf(buf, sizeof(buf), "%d", (int)lroundf(f->high_f.value));
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(fc_temp[i], buf);
}
}
void ui_update(const ha_data_t *data)
{
if (data == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped update: LVGL lock timed out");
return;
}
label_set_if_changed(clock_label, data->clock[0] ? data->clock : "--:--");
label_set_if_changed(date_label, data->date[0] ? data->date : "--");
label_set_if_changed(cond_label, ha_condition_label(data->condition));
update_body(data);
update_forecast(data);
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
/* Restyling a tile is many style writes; skip the ones that did not
* move. */
if (last_light_valid &&
last_light[i].state == data->lights[i].state &&
last_light[i].brightness == data->lights[i].brightness) {
continue;
}
light_tile_update(i, &data->lights[i]);
last_light[i] = data->lights[i];
}
last_light_valid = true;
lvgl_port_unlock();
}
void ui_set_weather_stale(bool stale)
void ui_set_stale(bool stale)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
@@ -101,7 +634,7 @@ void ui_set_weather_stale(bool stale)
/* Unlike lv_label_set_text(), setting an identical style value does
* not invalidate, so this is safe to call on every poll. */
lv_obj_set_style_text_color(temp_label,
stale ? lv_color_hex(0x5A6672) : lv_color_white(),
lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN);
}
@@ -124,6 +657,11 @@ void ui_set_status(const char *status)
lvgl_port_unlock();
}
void ui_set_light_cb(ui_light_cmd_cb_t cb)
{
light_cb = cb;
}
void ui_init(void)
{
/* ui_init() runs from app_main, not the LVGL task, so it needs the lock
@@ -134,59 +672,27 @@ void ui_init(void)
}
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A2332), LV_PART_MAIN);
lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), LV_PART_MAIN);
lv_obj_set_style_pad_all(scr, SCREEN_PAD, LV_PART_MAIN);
lv_obj_set_style_pad_row(scr, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(scr, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *header = lv_obj_create(scr);
lv_obj_set_size(header, LV_PCT(100), 64);
lv_obj_set_style_radius(header, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(header, lv_color_hex(0x0D1620), LV_PART_MAIN);
lv_obj_set_style_border_width(header, 0, LV_PART_MAIN);
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
build_header(scr);
build_body(scr);
build_lights(scr);
build_forecast(scr);
lv_obj_t *title = lv_label_create(header);
lv_label_set_text(title, "Thermostat");
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_text_font(title, &lv_font_montserrat_28, LV_PART_MAIN);
lv_obj_center(title);
lv_obj_t *card = lv_obj_create(scr);
lv_obj_set_size(card, LV_PCT(80), LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(card, lv_color_hex(0x2C3E50), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 2, LV_PART_MAIN);
lv_obj_set_style_border_color(card, lv_color_hex(0x3498DB), LV_PART_MAIN);
lv_obj_set_style_radius(card, 12, LV_PART_MAIN);
lv_obj_set_style_pad_all(card, 24, LV_PART_MAIN);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
temp_label = lv_label_create(card);
lv_label_set_text(temp_label, "--°F");
lv_obj_set_style_text_color(temp_label, lv_color_white(), LV_PART_MAIN);
lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, LV_PART_MAIN);
cond_label = lv_label_create(card);
lv_label_set_text(cond_label, "Waiting for data...");
lv_obj_set_style_text_color(cond_label, lv_color_hex(0xBDC3C7), LV_PART_MAIN);
status_label = lv_label_create(scr);
lv_label_set_text(status_label, "Starting...");
lv_obj_set_style_text_color(status_label, lv_color_hex(0x7F8C8D), LV_PART_MAIN);
#ifdef UI_DEBUG_TOUCH
touch_label = lv_label_create(scr);
lv_label_set_text(touch_label, "touch --, --");
lv_obj_set_style_text_color(touch_label, lv_color_hex(0x3498DB), LV_PART_MAIN);
lv_timer_create(touch_readout_timer_cb, 100, NULL);
#endif
/* Its own band, accounted for in the BAND_* budget. Usually says
* something dull about WiFi; when a poll starts failing it is the only
* place that says so. */
status_label = label_create(scr, "Starting...", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_height(status_label, BAND_STATUS_H);
lv_obj_set_width(status_label, LV_PCT(100));
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lvgl_port_unlock();
ESP_LOGI(TAG, "UI initialized");
ESP_LOGI(TAG, "UI initialized (%d lights, %d forecast days)",
HA_LIGHT_COUNT, HA_FORECAST_DAYS);
}
+31 -3
View File
@@ -3,19 +3,47 @@
#include <stdbool.h>
#include <stdint.h>
#include "ha_data.h"
#include "lvgl.h"
/*
* The screen layout.
*
* This file is compiled into the desktop simulator as well as the firmware
* (see sim/README.md), so it must never touch a driver, a socket or ESP-IDF
* directly. Data arrives as ha_data_t; commands leave through the callback
* below. Everything in between is drawing.
*/
void ui_init(void);
/* Safe to call from any task -- these take the LVGL lock internally. */
void ui_update_weather(int16_t temp_f, const char *condition);
/* Redraw every panel from `data`. Cheap to call when nothing changed: every
* label goes through a comparison first, so an unchanged value costs no
* invalidation and therefore no flush. */
void ui_update(const ha_data_t *data);
void ui_set_status(const char *status);
/*
* Mark the displayed reading as no longer trustworthy. Dims the temperature
* Mark the displayed readings as no longer trustworthy. Dims the temperature
* so a value that stopped updating is visibly distinct from a live one,
* rather than sitting there at full styling looking current.
*/
void ui_set_weather_stale(bool stale);
void ui_set_stale(bool stale);
/*
* Called when the user operates a light tile.
*
* `index` indexes HA_LIGHTS. `brightness` is 0-255, or -1 for a plain on/off
* with no brightness change (a tile tap, or any switch-domain entity).
*
* Runs on the LVGL task with the LVGL lock held, so the implementation must
* not block: hand the command to another task rather than doing HTTP here.
*/
typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness);
void ui_set_light_cb(ui_light_cmd_cb_t cb);
#endif