/* * 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 #include #include #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; /* * 2x2 reading grid. * * Was 3x2. The thermostat controls need finger-sized targets and that space * had to come from somewhere; wind was the right thing to cut, since the * station anemometer has been dead since 2026-07-27 and the cell has never * shown a number. Sunrise and sunset share one cell now. */ enum { CELL_OUTDOOR = 0, CELL_RAIN, CELL_SUN, 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; /* Thermostat card */ static lv_obj_t *therm_state_label; static lv_obj_t *therm_state_dot; static lv_obj_t *setpoint_label; static lv_obj_t *mode_btn; static lv_obj_t *mode_btn_label; static lv_obj_t *fan_btn; static lv_obj_t *fan_btn_label; static ui_light_cmd_cb_t light_cb; static ui_therm_cmd_cb_t therm_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]; /* * There is deliberately no "repair" repaint here any more. * * An earlier version scheduled a full-screen invalidate 600ms after each * interaction, on the theory that repainting would clear glitched pixels * LVGL does not know are wrong. It made the artefacts worse and constant * rather than occasional, for the reason the README already gave: the * corruption scales with how much is written, because LVGL's PSRAM writes * compete with the panel's DMA reads out of the same PSRAM. Repainting to * fix corruption caused by repainting does not converge. * * The lever that actually works is writing less. Everything below is * change-guarded: if a value has not moved, no style is written and nothing * is invalidated. */ /* --------------------------------------------------------- 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; } /* * The colour write is skipped when the glyph did not change. Style writes * invalidate unconditionally, so an unguarded one here would repaint * three arrows on every poll to redraw the same pixels. */ const char *glyph = (t == HA_TREND_RISING) ? ICON_TREND_UP : (t == HA_TREND_FALLING) ? ICON_TREND_DOWN : ""; const char *current = lv_label_get_text(icon); if (current != NULL && strcmp(current, glyph) == 0) { return; } lv_label_set_text(icon, glyph); if (t == HA_TREND_RISING) { lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN); } else if (t == HA_TREND_FALLING) { lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN); } } /* ------------------------------------------------------ thermostat controls */ /* * A flat touch button with a centred label. `out_label` is optional -- pass * it for buttons whose caption changes (mode, fan), omit for fixed ones. * * Sized by the caller rather than by content: these are finger targets on a * capacitive panel, so the hit area is the specification, not the text. */ static lv_obj_t *button_create(lv_obj_t *parent, int32_t w, int32_t h, const char *text, lv_obj_t **out_label) { lv_obj_t *btn = lv_obj_create(parent); lv_obj_set_size(btn, w, h); lv_obj_set_style_radius(btn, 8, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); lv_obj_set_style_bg_color(btn, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN); /* Visible press feedback matters more here than on the light tiles: these * act instantly and locally, so the flash is the whole acknowledgement. */ lv_obj_set_style_bg_color(btn, lv_color_hex(COL_ACCENT), LV_STATE_PRESSED); lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); lv_obj_t *lbl = label_create(btn, text, &lv_font_montserrat_16, COL_TEXT); lv_obj_center(lbl); if (out_label != NULL) { *out_label = lbl; } return btn; } /* * Runs on the LVGL task with the lock held. Unlike the light commands there * is no network involved, so the callback applies the change and pushes the * new state straight back -- the LVGL lock is recursive, so re-entering * ui_update_thermostat() from here is fine. */ static void therm_button_clicked(lv_event_t *e) { const ui_therm_cmd_t cmd = (ui_therm_cmd_t)(intptr_t)lv_event_get_user_data(e); if (therm_cb != NULL) { therm_cb(cmd); } } /* ------------------------------------------------------------ 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); /* -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); 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; 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); /* --- thermostat card: current temperature and what the furnace is doing --- */ lv_obj_t *card = panel_create(row, 300, LV_PCT(100), COL_PANEL); lv_obj_set_style_pad_all(card, 12, 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); temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT); lv_obj_align(temp_label, LV_ALIGN_TOP_LEFT, 0, 22); /* 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_14, COL_TEXT_DIM); lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78); /* Status dot plus text: the dot carries the state as colour so it reads * from across the room without parsing a word. */ therm_state_dot = lv_obj_create(card); lv_obj_set_size(therm_state_dot, 12, 12); lv_obj_set_style_radius(therm_state_dot, 6, LV_PART_MAIN); lv_obj_set_style_border_width(therm_state_dot, 0, LV_PART_MAIN); lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(COL_TEXT_FAINT), LV_PART_MAIN); lv_obj_remove_flag(therm_state_dot, LV_OBJ_FLAG_SCROLLABLE); lv_obj_align(therm_state_dot, LV_ALIGN_TOP_LEFT, 2, 104); therm_state_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM); lv_obj_align(therm_state_label, LV_ALIGN_TOP_LEFT, 22, 100); /* Two buttons across the bottom of the card. 134px each is a comfortable * finger target; the row fills the card's 276px inner width. */ mode_btn = button_create(card, 134, 40, "Heat", &mode_btn_label); lv_obj_align(mode_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); lv_obj_add_event_cb(mode_btn, therm_button_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)UI_THERM_MODE_TOGGLE); fan_btn = button_create(card, 134, 40, "Fan auto", &fan_btn_label); lv_obj_align(fan_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); lv_obj_add_event_cb(fan_btn, therm_button_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)UI_THERM_FAN_TOGGLE); /* --- setpoint column: up, value, down --- */ lv_obj_t *setcol = panel_create(row, 120, LV_PCT(100), COL_PANEL); lv_obj_set_style_pad_all(setcol, 8, LV_PART_MAIN); lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL); lv_obj_align(up_btn, LV_ALIGN_TOP_MID, 0, 0); lv_obj_add_event_cb(up_btn, therm_button_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)UI_THERM_SETPOINT_UP); lv_obj_t *set_cap = label_create(setcol, "SET", &lv_font_montserrat_12, COL_TEXT_FAINT); lv_obj_set_style_text_letter_space(set_cap, 1, LV_PART_MAIN); lv_obj_align(set_cap, LV_ALIGN_CENTER, 0, -14); setpoint_label = label_create(setcol, "--", &lv_font_montserrat_32, COL_ACCENT_WARM); lv_obj_align(setpoint_label, LV_ALIGN_CENTER, 0, 8); lv_obj_t *down_btn = button_create(setcol, LV_PCT(100), 52, "-", NULL); lv_obj_align(down_btn, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)UI_THERM_SETPOINT_DOWN); /* --- 2x2 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", "RAIN TODAY", "SUN", "PRESSURE", }; static const char *const cell_icons[CELL_COUNT] = { ICON_THERMOMETER, ICON_WATER, 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, 172, 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); /* * Wind lives on the outdoor cell's sub-line now that it has no cell of * its own -- the station anemometer has been offline since 2026-07-27 and * a dedicated cell showing "--" was not worth the space the thermostat * controls needed. It is still deliberately NOT backfilled from the * met.no forecast: regional modelled wind under a heading that means "my * yard" is worse than showing nothing. * * When the sensor comes back it displaces the dew point here, which is * both a useful reading and a visible sign the hardware is alive again. */ if (d->wind_mph.valid) { if (d->wind_dir_deg.valid) { snprintf(sub, sizeof(sub), "%.0f%% %.0f mph %s", d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f, d->wind_mph.value, ha_wind_cardinal(d->wind_dir_deg.value)); } else { snprintf(sub, sizeof(sub), "%.0f%% %.0f mph", d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f, d->wind_mph.value); } } else 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); /* 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. One cell for both times now: the value line shows whichever is * next, and the sub-line the other, so the useful number is the big one. * These strings are already local time from HA -- the board has no clock. */ /* Plain hyphen as the separator, not a middot -- Montserrat has no * U+00B7 and it renders as a tofu box. See the note on NO_VALUE. */ if (d->sun_up) { label_set_if_changed(cell_value[CELL_SUN], d->sunset[0] ? d->sunset : NO_VALUE); snprintf(sub, sizeof(sub), "sets - rose %s", d->sunrise[0] ? d->sunrise : NO_VALUE); } else { label_set_if_changed(cell_value[CELL_SUN], d->sunrise[0] ? d->sunrise : NO_VALUE); snprintf(sub, sizeof(sub), "rises - set %s", d->sunset[0] ? d->sunset : NO_VALUE); } label_set_if_changed(cell_sub[CELL_SUN], sub); /* 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); const char *current = lv_label_get_text(fc_icon[i]); if (current == NULL || strcmp(current, ci->icon) != 0) { lv_label_set_text(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); /* Colour only when the glyph moved -- see trend_render(). */ const char *current = lv_label_get_text(cond_icon); if (current == NULL || strcmp(current, ci->icon) != 0) { lv_label_set_text(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) { /* * Guarded, because setting an identical style value is NOT free -- * lv_obj_set_local_style_prop() calls lv_obj_refresh_style() * unconditionally and invalidates the object. An earlier comment here * claimed the opposite and was wrong; called every poll, it repainted the * temperature for no reason. */ static bool last_stale; static bool last_stale_valid; if (last_stale_valid && last_stale == stale) { return; } if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out"); return; } if (temp_label != NULL) { lv_obj_set_style_text_color(temp_label, lv_color_hex(stale ? COL_STALE : COL_TEXT), LV_PART_MAIN); } last_stale = stale; last_stale_valid = true; 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_set_therm_cb(ui_therm_cmd_cb_t cb) { therm_cb = cb; } /* * Previous state pushed to the thermostat card. This is not an optimisation, * it is the fix for a real bug: this function is called once a second by the * control task, and lv_obj_set_local_style_prop() does NOT skip identical * values -- it calls lv_obj_refresh_style() unconditionally, which * invalidates. Writing seven styles per second was seven redraws per second * forever, which is what turned the left-edge artefacts from occasional into * permanent. */ static therm_status_t last_therm; static bool last_therm_valid; void ui_update_thermostat(const therm_status_t *st) { if (st == NULL) { return; } const bool first = !last_therm_valid; /* The common case by far: nothing has moved, so touch nothing and do not * even take the lock. */ if (!first && st->state == last_therm.state && st->mode == last_therm.mode && st->fan == last_therm.fan && st->lockout_remaining_s == last_therm.lockout_remaining_s && (int)lroundf(st->setpoint_f) == (int)lroundf(last_therm.setpoint_f)) { return; } if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out"); return; } char buf[32]; const bool heat_mode = (st->mode == THERM_MODE_HEAT); const bool mode_changed = first || st->mode != last_therm.mode; if (first || mode_changed || (int)lroundf(st->setpoint_f) != (int)lroundf(last_therm.setpoint_f)) { snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f)); label_set_if_changed(setpoint_label, buf); /* Greys out when the mode is off -- it still adjusts, it just is not * controlling anything. */ lv_obj_set_style_text_color(setpoint_label, lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_TEXT_FAINT), LV_PART_MAIN); } /* Text changes every second while a lockout counts down, but it is one * small label rather than the whole card. */ if (first || st->state != last_therm.state || st->lockout_remaining_s != last_therm.lockout_remaining_s) { thermostat_state_text(st, buf, sizeof(buf)); label_set_if_changed(therm_state_label, buf); } if (first || st->state != last_therm.state) { /* * The dot colour is the at-a-glance channel: amber means the burner * is lit, warm orange means it wants to run but a protection timer * says not yet, red means no heat is coming at all. */ uint32_t dot = COL_TEXT_FAINT; uint32_t text = COL_TEXT_DIM; switch (st->state) { case THERM_STATE_HEATING: dot = COL_ON; text = COL_ON; break; case THERM_STATE_LOCKOUT: dot = COL_ACCENT_WARM; text = COL_ACCENT_WARM; break; case THERM_STATE_NO_SENSOR: dot = 0xE74C3C; text = 0xE74C3C; break; default: break; } lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN); lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN); } if (mode_changed) { label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off"); lv_obj_set_style_bg_color(mode_btn, lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI), LV_PART_MAIN); lv_obj_set_style_text_color(mode_btn_label, lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM), LV_PART_MAIN); } if (first || st->fan != last_therm.fan) { const bool fan_on = (st->fan == THERM_FAN_ON); label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto"); lv_obj_set_style_bg_color(fan_btn, lv_color_hex(fan_on ? COL_ACCENT : COL_PANEL_HI), LV_PART_MAIN); lv_obj_set_style_text_color(fan_btn_label, lv_color_hex(fan_on ? 0x05131F : COL_TEXT), LV_PART_MAIN); } last_therm = *st; last_therm_valid = true; lvgl_port_unlock(); } 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); 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"); }