The artefacts went from occasional to continuous, and the cause was my own
mitigation for them.
lv_obj_set_local_style_prop() does NOT skip identical values -- it calls
lv_obj_refresh_style() unconditionally, which invalidates the object. A
comment in ui_set_stale() claimed the opposite and that assumption spread.
ui_update_thermostat() is called once a second by the control task and was
writing seven style properties every time, so the panel was redrawing
something forever, and glitched lines were being regenerated as fast as
anything could clear them.
Two full-screen invalidates made it worse still: one after every HA poll,
one 600ms after every touch. The premise was that LVGL never reinvalidates
pixels it does not know are wrong, so repainting would clean them up. But
the README already had the answer -- 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. A full repaint is 768KB of exactly the contention that
causes the problem. Both are removed.
What is left is the thing that actually helps: write less.
- ui_update_thermostat() returns without taking the lock when nothing
moved, and otherwise touches only the widgets whose values changed.
- ui_set_stale() is guarded on the previous value.
- Condition and trend icons only get a colour write when the glyph itself
changed.
Also raises the bounce buffer from 16 to 20 scanlines. The comment saying
20 "fails to allocate once WiFi is up" was true when display_init() ran
after wifi_init_sta(); it does not any more. display_alloc() takes the
buffers from a virgin heap with ~200KB free, so the extra 12.8KB is
affordable, and each larger chunk buys the refill ISR more time to fetch the
next one before the LCD needs it. That attacks the mechanism rather than the
symptom. ~24KB internal heap remains once WiFi is up; the "Free internal
heap" log line is the thing to watch if it needs to come back down.
Both gotchas are now in the README so the next person does not reach for a
repaint again.
Co-Authored-By: Claude <noreply@anthropic.com>
1358 lines
52 KiB
C
1358 lines
52 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;
|
|
|
|
/*
|
|
* 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");
|
|
}
|