/* * 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; /* 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]; 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]; /* --------------------------------------------------------- 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; } /* ------------------------------------------------------------ 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); } 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); /* 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_apply_optimistic(idx, (ha_light_status_t){ HA_LIGHT_OFF, -1 }); light_cb(idx, false, -1); return; } 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); } 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); /* * RELEASED only: the command is sent when the finger lifts, once. * VALUE_CHANGED fires per pixel of drag and would queue a command * storm; it is also why ui_update never writes to a pressed slider * -- nothing may move the knob out from under the finger. */ 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; 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); 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); 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); lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8); 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); 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); 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); 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"); }