Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.
The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.
- The needle does not animate. A spin would look better, but every animated
frame is a redraw, and redrawing is what starves the bounce refill ISR.
- Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
being pushed reports whatever it last settled on, which on this station
is a steady 0 degrees -- a confident needle pointing due north at zero
wind. The live snapshot caught exactly that.
- The trend is local, not HA's 10-minute average, so the window is ours to
pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.
That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.
Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.
On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.
Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.
Co-Authored-By: Claude <noreply@anthropic.com>
1516 lines
58 KiB
C
1516 lines
58 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 "--"
|
|
|
|
/* Compass dial diameter. Sized to the 120px column with its 8px padding. */
|
|
#define COMPASS_SIZE 96
|
|
|
|
/* 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; /* downstairs, the primary reading */
|
|
static lv_obj_t *temp_unit_label;
|
|
static lv_obj_t *indoor_detail_label;
|
|
static lv_obj_t *trend_indoor_icon;
|
|
static lv_obj_t *upstairs_value_label; /* secondary, deliberately small */
|
|
static lv_obj_t *upstairs_sub_label;
|
|
static lv_obj_t *trend_upstairs_icon;
|
|
|
|
/* Wind compass. The needle is an lv_line whose two points are recomputed on
|
|
* each update -- no image asset, no rotation transform, and the dirty area is
|
|
* just the dial. */
|
|
static lv_obj_t *compass_dial;
|
|
static lv_obj_t *compass_needle;
|
|
static lv_obj_t *compass_north;
|
|
static lv_obj_t *wind_speed_label;
|
|
static lv_obj_t *wind_unit_label;
|
|
static lv_obj_t *wind_dir_label;
|
|
static lv_obj_t *wind_trend_icon;
|
|
static lv_point_precise_t compass_pts[2];
|
|
|
|
/*
|
|
* 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, 296, LV_PCT(100), COL_PANEL);
|
|
lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN);
|
|
|
|
lv_obj_t *heading = label_create(card, "DOWNSTAIRS", &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);
|
|
|
|
/*
|
|
* Upstairs sits beside the big number rather than under it: same card,
|
|
* obviously subordinate by size, and it costs no vertical space in a
|
|
* layout that has none to spare.
|
|
*/
|
|
lv_obj_t *up_cap = label_create(card, "UPSTAIRS", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
|
lv_obj_set_style_text_letter_space(up_cap, 1, LV_PART_MAIN);
|
|
lv_obj_align(up_cap, LV_ALIGN_TOP_RIGHT, 0, 26);
|
|
|
|
upstairs_value_label = label_create(card, NO_VALUE, &lv_font_montserrat_24, COL_TEXT_DIM);
|
|
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
|
|
|
trend_upstairs_icon = icon_create(card, "", icon_font_16, COL_TEXT_DIM);
|
|
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
|
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
|
|
|
upstairs_sub_label = label_create(card, "", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
|
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
|
|
|
/* 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, 132, 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, 132, 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, 110, 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);
|
|
|
|
/* --- wind compass --- */
|
|
lv_obj_t *windcol = panel_create(row, 120, LV_PCT(100), COL_PANEL);
|
|
lv_obj_set_style_pad_all(windcol, 8, LV_PART_MAIN);
|
|
|
|
lv_obj_t *wind_cap = label_create(windcol, "WIND", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
|
lv_obj_set_style_text_letter_space(wind_cap, 1, LV_PART_MAIN);
|
|
lv_obj_align(wind_cap, LV_ALIGN_TOP_LEFT, 0, 0);
|
|
|
|
/* The dial is just a bordered circle; the needle is drawn over it. */
|
|
compass_dial = lv_obj_create(windcol);
|
|
lv_obj_set_size(compass_dial, COMPASS_SIZE, COMPASS_SIZE);
|
|
lv_obj_set_style_radius(compass_dial, COMPASS_SIZE / 2, LV_PART_MAIN);
|
|
lv_obj_set_style_bg_color(compass_dial, lv_color_hex(COL_TRACK), LV_PART_MAIN);
|
|
lv_obj_set_style_border_width(compass_dial, 2, LV_PART_MAIN);
|
|
lv_obj_set_style_border_color(compass_dial, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN);
|
|
lv_obj_set_style_pad_all(compass_dial, 0, LV_PART_MAIN);
|
|
lv_obj_remove_flag(compass_dial, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_align(compass_dial, LV_ALIGN_TOP_MID, 0, 18);
|
|
|
|
compass_north = label_create(compass_dial, "N", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
|
lv_obj_align(compass_north, LV_ALIGN_TOP_MID, 0, 2);
|
|
|
|
compass_needle = lv_line_create(compass_dial);
|
|
lv_obj_set_style_line_width(compass_needle, 3, LV_PART_MAIN);
|
|
lv_obj_set_style_line_color(compass_needle, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
|
|
lv_obj_set_style_line_rounded(compass_needle, true, LV_PART_MAIN);
|
|
compass_pts[0] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
|
compass_pts[1] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
|
|
|
wind_dir_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_14, COL_TEXT_DIM);
|
|
lv_obj_align(wind_dir_label, LV_ALIGN_TOP_MID, 0, 18 + COMPASS_SIZE + 4);
|
|
|
|
wind_speed_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_20, COL_TEXT);
|
|
lv_obj_align(wind_speed_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
|
|
|
wind_unit_label = label_create(windcol, "mph", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
|
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
|
|
|
wind_trend_icon = icon_create(windcol, "", icon_font_16, COL_TEXT_DIM);
|
|
lv_obj_align(wind_trend_icon, LV_ALIGN_BOTTOM_RIGHT, 0, -2);
|
|
|
|
/* --- 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", "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, 117, cell_h, COL_PANEL);
|
|
lv_obj_set_style_pad_all(cell, 8, 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_20, 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];
|
|
|
|
/* Downstairs -- the primary reading, and what the furnace controls on. */
|
|
if (d->indoor_down_temp_f.valid) {
|
|
snprintf(buf, sizeof(buf), "%.1f", d->indoor_down_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_down);
|
|
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
|
|
|
|
if (d->indoor_down_humidity.valid) {
|
|
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_down_humidity.value);
|
|
} else {
|
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
|
}
|
|
label_set_if_changed(indoor_detail_label, buf);
|
|
|
|
/* Upstairs -- same card, smaller, right-aligned. */
|
|
if (d->indoor_up_temp_f.valid) {
|
|
snprintf(buf, sizeof(buf), "%.1f°", d->indoor_up_temp_f.value);
|
|
} else {
|
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
|
}
|
|
label_set_if_changed(upstairs_value_label, buf);
|
|
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
|
trend_render(trend_upstairs_icon, d->trend_indoor_up);
|
|
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
|
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
|
|
|
if (d->indoor_up_humidity.valid) {
|
|
snprintf(sub, sizeof(sub), "%.0f%% RH", d->indoor_up_humidity.value);
|
|
} else {
|
|
sub[0] = '\0';
|
|
}
|
|
label_set_if_changed(upstairs_sub_label, sub);
|
|
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
|
|
|
/* 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 has its own compass column now, so the outdoor sub-line goes back
|
|
* to humidity and dew point. */
|
|
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%% RH", 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), "sunset");
|
|
} else {
|
|
label_set_if_changed(cell_value[CELL_SUN],
|
|
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
|
snprintf(sub, sizeof(sub), "sunrise");
|
|
}
|
|
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");
|
|
}
|
|
|
|
/*
|
|
* Point the needle at the direction the wind is coming FROM, which is what a
|
|
* weather vane does and what the cardinal label says. 0 degrees is north, at
|
|
* the top of the dial, increasing clockwise -- so screen x uses sin and y uses
|
|
* -cos, because screen y grows downward.
|
|
*
|
|
* The needle deliberately does not animate. A spin would look better, but
|
|
* every animated frame is a redraw, and redrawing is what starves the bounce
|
|
* refill ISR (README, PSRAM bandwidth). Once a minute it snaps, and the dirty
|
|
* area is just the dial.
|
|
*/
|
|
static void compass_render(const ha_data_t *d)
|
|
{
|
|
if (compass_needle == NULL) {
|
|
return;
|
|
}
|
|
|
|
const int32_t c = COMPASS_SIZE / 2;
|
|
|
|
/*
|
|
* Below this, treat it as calm and draw no needle.
|
|
*
|
|
* A vane that is not being pushed reports whatever it last settled on --
|
|
* with the station becalmed that is a steady 0 degrees, which renders as a
|
|
* confident needle pointing due north. "Calm" is the honest reading; an
|
|
* arrow would be inventing information the sensor did not supply.
|
|
*/
|
|
const bool calm = d->wind_mph.valid && d->wind_mph.value < 0.5f;
|
|
|
|
if (!d->wind_dir_deg.valid || calm) {
|
|
/* Degenerate zero-length line: nothing drawn, dial stays as a dial. */
|
|
if (compass_pts[1].x != c || compass_pts[1].y != c) {
|
|
compass_pts[0] = (lv_point_precise_t){ c, c };
|
|
compass_pts[1] = (lv_point_precise_t){ c, c };
|
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
|
}
|
|
label_set_if_changed(wind_dir_label, calm ? "Calm" : NO_VALUE);
|
|
return;
|
|
}
|
|
|
|
const float rad = d->wind_dir_deg.value * (float)M_PI / 180.0f;
|
|
const float head_r = (float)c - 8.0f; /* stop inside the border */
|
|
const float tail_r = (float)c * 0.35f; /* short tail through centre */
|
|
|
|
const int32_t hx = c + (int32_t)lroundf(head_r * sinf(rad));
|
|
const int32_t hy = c - (int32_t)lroundf(head_r * cosf(rad));
|
|
const int32_t tx = c - (int32_t)lroundf(tail_r * sinf(rad));
|
|
const int32_t ty = c + (int32_t)lroundf(tail_r * cosf(rad));
|
|
|
|
/* Only rewrite the points when the needle actually moved --
|
|
* lv_line_set_points() invalidates unconditionally. */
|
|
if (compass_pts[0].x != tx || compass_pts[0].y != ty ||
|
|
compass_pts[1].x != hx || compass_pts[1].y != hy) {
|
|
compass_pts[0] = (lv_point_precise_t){ tx, ty };
|
|
compass_pts[1] = (lv_point_precise_t){ hx, hy };
|
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
|
}
|
|
|
|
label_set_if_changed(wind_dir_label, ha_wind_cardinal(d->wind_dir_deg.value));
|
|
}
|
|
|
|
static void update_wind(const ha_data_t *d)
|
|
{
|
|
char buf[24];
|
|
|
|
compass_render(d);
|
|
|
|
if (d->wind_mph.valid) {
|
|
snprintf(buf, sizeof(buf), "%.1f", d->wind_mph.value);
|
|
} else {
|
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
|
}
|
|
label_set_if_changed(wind_speed_label, buf);
|
|
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
|
|
|
/* The arrow is the 5-minute trend in average speed, not the instantaneous
|
|
* reading -- see HA_TREND_CH_WIND. */
|
|
trend_render(wind_trend_icon, d->trend_wind);
|
|
}
|
|
|
|
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_wind(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");
|
|
}
|