Files
thermostat-hw/main/ui/ui_manager.c
T

1118 lines
42 KiB
C

/*
* Dashboard layout for the 800x480 panel.
*
* Five horizontal bands, sized to fill exactly one screen with no scrolling:
*
* header 42px date, clock, current condition (icon + text)
* body 182px INDOOR temperature card + a 3x2 grid of readings
* lights 116px one tile per HA_LIGHTS entry
* forecast 86px five day columns with condition icons
* status 14px WiFi / poll failures
*
* Indoor gets the big card because this is a thermostat: the number you act
* on is the one inside the house. Outdoor lives in the grid.
*
* Nothing is hidden behind navigation: this is a wall panel that has to be
* readable from across the room at a glance.
*
* Icons are single glyphs from a subset MDI font rasterised by tiny_ttf --
* see tools/make_icon_font.py. They are ordinary label text: colourable,
* resizable, and cheap.
*
* 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>
#include "esp_log.h"
#include "lvgl.h"
#include "lvgl_port.h"
#include "ui_icons.h"
#include "ui_manager.h"
static const char *TAG = "ui_manager";
#define UI_LOCK_TIMEOUT_MS 1000
/*
* How long a tile shows the state the user asked for before Home Assistant's
* word is taken over the user's. Long enough to cover command + confirm poll
* (typically <1s, but group entities settle slower); short enough that a
* genuinely failed command corrects itself rather than lying all day.
*/
#define OPTIMISTIC_HOLD_MS 5000
/*
* 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 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
/* 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 */
/*
* 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 "--"
/* Icon fonts, one per size in use. Created in ui_init from the embedded
* subset TTF; NULL if tiny_ttf fails, in which case icons render empty
* rather than as tofu. */
static lv_font_t *icon_font_16;
static lv_font_t *icon_font_22;
static lv_font_t *icon_font_26;
static lv_obj_t *clock_label;
static lv_obj_t *date_label;
static lv_obj_t *cond_label;
static lv_obj_t *cond_icon;
static lv_obj_t *temp_label;
static lv_obj_t *temp_unit_label;
static lv_obj_t *indoor_detail_label;
static lv_obj_t *trend_indoor_icon;
/* 3x2 reading grid: value and caption per cell. */
enum {
CELL_OUTDOOR = 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];
/* Trend arrows; only the outdoor and pressure slots are created. */
static lv_obj_t *cell_trend[CELL_COUNT];
static lv_obj_t *light_tile[HA_LIGHT_COUNT];
static lv_obj_t *light_icon[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_icon[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;
/* Per-room icons, riding in HA_LIGHT_LIST so order can never drift from the
* entity table. */
#define LIGHT_ICON_X(id, label, dimmable, icon) icon,
static const char *const LIGHT_ICONS[HA_LIGHT_COUNT] = {
HA_LIGHT_LIST(LIGHT_ICON_X)
};
/*
* 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;
/*
* Optimistic state: what the user just asked for, applied to the tile
* immediately so a tap responds at touch speed instead of at poll speed.
*
* While a hold is active, poll data for that tile is ignored unless it agrees
* -- HA often reports the OLD state for a beat after a command (group
* entities especially), and applying that would visibly snap the tile back
* before flipping it again a second later. After OPTIMISTIC_HOLD_MS, HA wins
* unconditionally: if it still says off, the command genuinely failed and the
* tile should say so.
*/
typedef struct {
bool active;
uint32_t since; /* lv_tick timestamp */
ha_light_state_t expect;
} light_hold_t;
static light_hold_t light_hold[HA_LIGHT_COUNT];
/* Slider press bookkeeping: whether the press-down command has been sent for
* the current touch, and at what percentage, so the release command can be
* skipped when the finger never moved. */
static bool slider_touched[HA_LIGHT_COUNT];
static int32_t slider_sent_pct[HA_LIGHT_COUNT];
/*
* One-shot "repair" repaint, rescheduled by every interaction to fire 600ms
* after the last one.
*
* Redrawing is itself what glitches the panel: the flush's PSRAM writes
* contend with the LCD's scan-out, and a burst of interactive redraws is the
* most likely moment for the bounce ISR to lose (README, PSRAM bandwidth).
* The glitched lines then persist because LVGL believes those pixels are
* fine. One full repaint after the burst settles cleans them up within
* ~0.6s instead of leaving them until the next poll's repaint, up to 60s
* away. It runs once per burst, not periodically -- a constant repaint loop
* would be a constant supply of fresh glitch opportunities.
*/
static lv_timer_t *repair_timer;
static void repair_timer_cb(lv_timer_t *t)
{
lv_obj_invalidate(lv_screen_active());
lv_timer_pause(t);
}
static void schedule_repair(void)
{
if (repair_timer != NULL) {
lv_timer_reset(repair_timer);
lv_timer_resume(repair_timer);
}
}
/* --------------------------------------------------------- condition icons */
typedef struct {
const char *slug;
const char *icon;
uint32_t colour;
} cond_icon_t;
/* met.no slugs -> glyph + colour. Warm for sun, blue for water, dim for the
* grey stuff -- the forecast strip reads at a glance without the words. */
static const cond_icon_t COND_ICONS[] = {
{ "clear-night", ICON_W_NIGHT, COL_TEXT_DIM },
{ "cloudy", ICON_W_CLOUDY, COL_TEXT_DIM },
{ "exceptional", ICON_W_ALERT, 0xE74C3C },
{ "fog", ICON_W_FOG, COL_TEXT_DIM },
{ "hail", ICON_W_HAIL, COL_ACCENT },
{ "lightning", ICON_W_LIGHTNING, COL_ON },
{ "lightning-rainy", ICON_W_LIGHTNING_RAIN, COL_ON },
{ "partlycloudy", ICON_W_PARTLY, COL_ON },
{ "pouring", ICON_W_POURING, COL_ACCENT },
{ "rainy", ICON_W_RAINY, COL_ACCENT },
{ "snowy", ICON_W_SNOWY, COL_TEXT },
{ "snowy-rainy", ICON_W_SNOWY_RAIN, COL_ACCENT },
{ "sunny", ICON_W_SUNNY, COL_ON },
{ "windy", ICON_W_WINDY, COL_TEXT_DIM },
{ "windy-variant", ICON_W_WINDY, COL_TEXT_DIM },
};
/* Unknown slug -> alert glyph in warning red: visibly "add me to the table",
* matching how ha_condition_label() passes unknown slugs through as text. */
static const cond_icon_t COND_ICON_FALLBACK = { NULL, ICON_W_ALERT, 0xE74C3C };
static const cond_icon_t *cond_icon_lookup(const char *slug)
{
if (slug != NULL) {
for (size_t i = 0; i < sizeof(COND_ICONS) / sizeof(COND_ICONS[0]); i++) {
if (strcmp(COND_ICONS[i].slug, slug) == 0) {
return &COND_ICONS[i];
}
}
}
return &COND_ICON_FALLBACK;
}
/* ------------------------------------------------------------- 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)
{
if (label == NULL || text == NULL) {
return;
}
const char *current = lv_label_get_text(label);
if (current != NULL && strcmp(current, text) == 0) {
return;
}
lv_label_set_text(label, text);
}
/* 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)
{
if (v->valid) {
snprintf(buf, len, fmt, v->value);
} else {
snprintf(buf, len, "%s", NO_VALUE);
}
}
static lv_obj_t *panel_create(lv_obj_t *parent, int32_t w, int32_t h, uint32_t colour)
{
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;
}
/* An icon is a one-glyph label in an icon font. With no font (tiny_ttf
* failed) the label is created empty, so layout stays put and nothing
* renders as a missing-glyph box. */
static lv_obj_t *icon_create(lv_obj_t *parent, const char *glyph,
lv_font_t *font, uint32_t colour)
{
lv_obj_t *l = lv_label_create(parent);
if (font != NULL) {
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_label_set_text(l, glyph);
} else {
lv_label_set_text(l, "");
}
lv_obj_set_style_text_color(l, lv_color_hex(colour), LV_PART_MAIN);
return l;
}
/*
* Trend arrow: up warm, down blue, nothing for flat or unknown. "No arrow"
* covers both "steady" and "not enough history yet" -- a reading that is
* going nowhere and a reading we cannot judge should both look calm.
*/
static void trend_render(lv_obj_t *icon, ha_trend_t t)
{
if (icon == NULL || icon_font_16 == NULL) {
return;
}
switch (t) {
case HA_TREND_RISING:
label_set_if_changed(icon, ICON_TREND_UP);
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
break;
case HA_TREND_FALLING:
label_set_if_changed(icon, ICON_TREND_DOWN);
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
break;
default:
label_set_if_changed(icon, "");
break;
}
}
/* ------------------------------------------------------------ light tiles */
static void light_tile_update(int idx, const ha_light_status_t *st);
/*
* Apply the state the user just requested, before Home Assistant has
* confirmed it. The tile flips at touch speed; the hold keeps the next
* poll's possibly-stale data from snapping it back. See light_hold_t.
*/
static void light_apply_optimistic(int idx, ha_light_status_t st)
{
light_hold[idx].active = true;
light_hold[idx].since = lv_tick_get();
light_hold[idx].expect = st.state;
last_light[idx] = st;
last_light_valid = true;
light_tile_update(idx, &st);
}
/*
* 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.
*
* LV_EVENT_CLICKED fires on RELEASE (press + release inside the object), so a
* tap commits when the finger lifts, and a finger that slides off the tile
* commits nothing.
*/
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;
}
/* 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);
/*
* Optimistic brightness: keep the last known level if we have one (HA
* restores the previous level on a plain turn_on), else -1 and the tile
* says "On" without a percentage until the poll fills it in.
*/
ha_light_status_t expect = {
.state = turn_on ? HA_LIGHT_ON : HA_LIGHT_OFF,
.brightness = (turn_on && last_light[idx].brightness > 0)
? last_light[idx].brightness
: -1,
};
light_apply_optimistic(idx, expect);
schedule_repair();
/* -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);
}
/* One command for a slider position. Dragging to zero means off, not "on at
* zero brightness" -- HA accepts brightness 0 but leaves the light on, which
* looks like a bug. */
static void light_send_pct(int idx, int32_t pct)
{
if (pct <= 0) {
light_apply_optimistic(idx, (ha_light_status_t){ HA_LIGHT_OFF, -1 });
light_cb(idx, false, -1);
} else {
const int16_t brightness = (int16_t)((pct * 255) / 100);
light_apply_optimistic(idx, (ha_light_status_t){ HA_LIGHT_ON, brightness });
light_cb(idx, true, brightness);
}
slider_sent_pct[idx] = pct;
}
/*
* Fires as the knob moves under a finger (programmatic set_value does not
* raise VALUE_CHANGED, so this is user input only). Two jobs:
*
* - keep the tile's readout live under the finger ("On 43%" tracks the
* drag), because the screen must follow the finger, never the light;
* - send ONE command at the first touch position, so the light comes on
* at the level the finger landed on without waiting for the lift.
*
* Everything between first touch and lift is display-only.
*/
static void light_slider_changed(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (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);
char buf[24];
if (pct <= 0) {
snprintf(buf, sizeof(buf), "Off");
} else {
snprintf(buf, sizeof(buf), "On %d%%", (int)pct);
}
label_set_if_changed(light_state[idx], buf);
lv_obj_set_style_text_color(light_state[idx],
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
schedule_repair();
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
return;
}
if (!slider_touched[idx]) {
slider_touched[idx] = true;
light_send_pct(idx, pct);
}
}
/* Finger lifted: send the final position -- unless the finger never moved
* from where the press-down command already put it. */
static void light_slider_released(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);
const bool touched = slider_touched[idx];
slider_touched[idx] = false;
schedule_repair();
if (touched && pct == slider_sent_pct[idx]) {
return; /* tap without drag: the press command already said this */
}
light_send_pct(idx, pct);
}
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 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.
*
* The icon gets its own row rather than sharing the name's. In a ~125px
* tile an icon beside the name leaves ~79px of text -- every room name
* truncated to "Main R...". Vertically there is space to burn;
* horizontally there is none.
*/
light_icon[idx] = icon_create(tile, LIGHT_ICONS[idx], icon_font_22, COL_TEXT_DIM);
lv_obj_align(light_icon[idx], LV_ALIGN_TOP_LEFT, 0, -2);
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);
/* The explicit one-line height is what actually forces truncation: with
* a content-sized height LV_LABEL_LONG_DOT wraps to a second line before
* it adds dots -- onto the state label below. */
lv_obj_set_size(light_name[idx], w - 20,
lv_font_get_line_height(&lv_font_montserrat_16));
lv_obj_align(light_name[idx], LV_ALIGN_TOP_LEFT, 0, 24);
light_state[idx] = label_create(tile, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_align(light_state[idx], LV_ALIGN_TOP_LEFT, 0, 46);
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);
/*
* Two commands per touch, not per pixel: VALUE_CHANGED sends one at
* the first touch position (light responds immediately where the
* finger lands), RELEASED sends the final position. In between, the
* drag only updates the display -- and ui_update never writes to a
* pressed slider, so nothing moves the knob out from under the
* finger.
*/
lv_obj_add_event_cb(s, light_slider_changed, LV_EVENT_VALUE_CHANGED,
(void *)(intptr_t)idx);
lv_obj_add_event_cb(s, light_slider_released, 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);
/* The room icon is the strongest "which lights are on" signal in the
* band: amber when lit, dim when off, faint when unreachable. */
lv_obj_set_style_text_color(light_icon[idx],
lv_color_hex(on ? COL_ON
: (dead ? COL_TEXT_FAINT : COL_TEXT_DIM)),
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;
/* Never move a knob a finger is holding -- the optimistic apply on
* press-down lands here while the drag is still in progress. */
if (!lv_obj_has_state(light_slider[idx], LV_STATE_PRESSED)) {
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);
}
}
}
/*
* True while this tile must not be touched by poll data: a finger is on it,
* or an optimistic hold is active and unresolved.
*/
static bool light_tile_busy(int idx, const ha_light_status_t *incoming)
{
/* Never move anything under a finger. The slider check is the one that
* keeps the knob pinned where the user is holding it. */
if (lv_obj_has_state(light_tile[idx], LV_STATE_PRESSED)) {
return true;
}
if (light_slider[idx] != NULL &&
lv_obj_has_state(light_slider[idx], LV_STATE_PRESSED)) {
return true;
}
light_hold_t *hold = &light_hold[idx];
if (!hold->active) {
return false;
}
/* HA agrees with what the user asked for: the hold has done its job.
* Fall through so the poll's real brightness lands on the tile. */
if (incoming->state == hold->expect) {
hold->active = false;
return false;
}
/* Still disagreeing after the window: the command failed, HA wins. */
if (lv_tick_elaps(hold->since) >= OPTIMISTIC_HOLD_MS) {
hold->active = false;
return false;
}
return true; /* disagreement inside the window: keep the user's state */
}
/* ------------------------------------------------------------------ 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);
/* Icon right-most; the text hangs off its left edge and is re-anchored
* on every update because its width changes with the words. */
cond_icon = icon_create(bar, ICON_W_CLOUDY, icon_font_26, COL_TEXT_DIM);
lv_obj_align(cond_icon, LV_ALIGN_RIGHT_MID, 0, 0);
cond_label = label_create(bar, "--", &lv_font_montserrat_20, COL_ACCENT);
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 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);
/* --- indoor card: the number a thermostat exists to show --- */
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, "INDOOR", &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);
lv_obj_t *card_icon = icon_create(card, ICON_HOME_THERMO, icon_font_26, COL_TEXT_FAINT);
lv_obj_align(card_icon, LV_ALIGN_TOP_RIGHT, 0, -2);
/* 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);
/* Unit sits high beside the number; the trend arrow sits low, so the
* pair reads as one annotated figure. */
trend_indoor_icon = icon_create(card, "", icon_font_26, COL_TEXT_DIM);
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
indoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM);
lv_obj_align(indoor_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] = {
"OUTDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE",
};
static const char *const cell_icons[CELL_COUNT] = {
ICON_THERMOMETER, ICON_W_WINDY, ICON_WATER,
ICON_SUNRISE, ICON_SUNSET, ICON_GAUGE,
};
/* 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);
lv_obj_t *ic = icon_create(cell, cell_icons[i], icon_font_16, COL_TEXT_FAINT);
lv_obj_align(ic, LV_ALIGN_TOP_RIGHT, 0, -1);
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);
/* Only readings with a meaningful hourly drift get an arrow slot. */
if (i == CELL_OUTDOOR || i == CELL_PRESSURE) {
cell_trend[i] = icon_create(cell, "", icon_font_16, COL_TEXT_DIM);
lv_obj_align_to(cell_trend[i], cell_value[i], LV_ALIGN_OUT_RIGHT_MID, 6, 0);
}
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_style_pad_all(col, 6, LV_PART_MAIN);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
fc_day[i] = label_create(col, "--", &lv_font_montserrat_16, COL_TEXT);
/* The icon replaces the abbreviated condition word: it says the same
* thing faster, in a column too narrow for real words. */
fc_icon[i] = icon_create(col, ICON_W_CLOUDY, icon_font_26, 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];
/* Indoor card. Rounded with lroundf, not truncation: below 0 degF a cast
* rounds toward zero and turns -10.6 into -10. */
if (d->indoor_temp_f.valid) {
snprintf(buf, sizeof(buf), "%.1f", d->indoor_temp_f.value);
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(temp_label, buf);
/* Both hang off the number, whose width just changed with its digits. */
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
trend_render(trend_indoor_icon, d->trend_indoor);
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
if (d->indoor_humidity.valid) {
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_humidity.value);
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(indoor_detail_label, buf);
/* Outdoor cell */
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(cell_value[CELL_OUTDOOR], buf);
trend_render(cell_trend[CELL_OUTDOOR], d->trend_outdoor);
lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR],
LV_ALIGN_OUT_RIGHT_MID, 6, 0);
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
d->outdoor_humidity.value, d->dew_point_f.value);
} else if (d->outdoor_humidity.valid) {
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value);
} else {
sub[0] = '\0';
}
label_set_if_changed(cell_sub[CELL_OUTDOOR], 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);
trend_render(cell_trend[CELL_PRESSURE], d->trend_pressure);
lv_obj_align_to(cell_trend[CELL_PRESSURE], cell_value[CELL_PRESSURE],
LV_ALIGN_OUT_RIGHT_MID, 6, 0);
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_icon[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);
if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(f->condition);
label_set_if_changed(fc_icon[i], ci->icon);
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
LV_PART_MAIN);
}
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));
if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(
data->condition[0] ? data->condition : NULL);
label_set_if_changed(cond_icon, ci->icon);
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
}
/* The label's width changed with its words; re-hang it off the icon. */
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
update_body(data);
update_forecast(data);
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
/* A finger on the tile, or an optimistic hold still waiting for HA
* to catch up: poll data keeps its hands off. */
if (light_tile_busy(i, &data->lights[i])) {
continue;
}
/* 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_stale(bool stale)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
return;
}
if (temp_label != NULL) {
/* 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,
lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN);
}
lvgl_port_unlock();
}
void ui_set_status(const char *status)
{
if (status == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped status update: LVGL lock timed out");
return;
}
label_set_if_changed(status_label, 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
* like any other outside caller. */
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGE(TAG, "Could not acquire LVGL lock for UI init");
return;
}
/*
* One tiny_ttf instance per size in use. The data is the ~6KB subset MDI
* font from tools/make_icon_font.py; a failure here degrades to iconless
* labels rather than stopping the UI.
*/
icon_font_16 = lv_tiny_ttf_create_data(icon_font_data, icon_font_data_size, 16);
icon_font_22 = lv_tiny_ttf_create_data(icon_font_data, icon_font_data_size, 22);
icon_font_26 = lv_tiny_ttf_create_data(icon_font_data, icon_font_data_size, 26);
if (icon_font_16 == NULL || icon_font_22 == NULL || icon_font_26 == NULL) {
ESP_LOGW(TAG, "tiny_ttf icon font failed; continuing without icons");
}
lv_obj_t *scr = lv_screen_active();
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_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
/* Created paused; interactions reset-and-resume it. See schedule_repair. */
repair_timer = lv_timer_create(repair_timer_cb, 600, NULL);
if (repair_timer != NULL) {
lv_timer_pause(repair_timer);
}
build_header(scr);
build_body(scr);
build_lights(scr);
build_forecast(scr);
/* 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 (%d lights, %d forecast days, icons %s)",
HA_LIGHT_COUNT, HA_FORECAST_DAYS,
icon_font_22 != NULL ? "on" : "OFF");
}