Files
thermostat-hw/main/ui/ui_manager.c
T
mattandClaude 3aabcac90a Add thermostat control for heat and fan with cycle protection
Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

Control loop (main/control/thermostat.c) is pure logic: no ESP-IDF, no
network, no clock of its own. The caller passes a temperature and a
millisecond timestamp, which is what lets the simulator and the test
harness run the identical decision code.

Equipment protections, all verified by tools/thermostat_test.c:

  deadband      1.0F    stops the burner chattering at the setpoint
  minimum on    3 min   a shorter call is all ignition wear, no heat
  minimum off   5 min   back-to-back ignitions are what kill igniters
  cycles/hour   5       standard gas default; catches a flapping sensor
  boot lockout  60 s    a boot loop must not machine-gun the gas valve

That test drives the state machine with synthetic time, so two hours of
furnace behaviour runs in milliseconds. It exits non-zero on failure and
is worth re-running after any edit to thermostat.c -- this switches a gas
valve.

Two deliberate decisions:

  - G is never energized during a heat call. The furnace runs its own
    blower with a fan-on delay that waits for the heat exchanger and a
    fan-off delay that scavenges residual heat; driving G ourselves would
    blow cold air at people and throw that heat away. G is manual
    circulation only.
  - The control loop starts before every early return in app_main. Those
    returns are all "no WiFi" or "no HA token", and none of them is a
    reason to leave a house unheated.

Relay pins are IO20 heat / IO19 fan on the UART1-OUT plug, measured on
this board since Elecrow documents the GPIO numbers but not the connector
pin order. Heat is on IO20 because it is USB D+, the line carrying the
hardware pull-up -- with an active-low module it fails toward "no heat"
rather than "heat stuck on" if the USB PHY is ever re-enabled. Both pins
were confirmed free of the onboard mic before use.
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG and CONFIG_ESP_PHY_ENABLE_USB are now
off to release them; the console is on UART0 via the CH340 regardless.

UI: the indoor card gains a status dot, mode and fan buttons, and a
setpoint column with finger-sized up/down targets. The reading grid drops
from six cells to four to make room -- wind was the one to cut, since the
station anemometer has been dead since 2026-07-27 and that cell has never
shown a number. Wind now rides on the outdoor cell's sub-line and will
reappear on its own if the sensor is fixed. Sunrise and sunset share a
cell, showing whichever comes next.

Known weakness, documented in README and top of TODO: indoor temperature
comes from Home Assistant over the network, so an HA outage ages the
reading until the 15-minute failsafe forces heat off. A local I2C sensor
removes the failure mode entirely and is the next thing to add. The
setpoint also resets to 68F on reboot, because persisting it means an NVS
write and a flash write while the panel scans faults the bounce-buffer
ISR.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 20:09:36 -07:00

1308 lines
49 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];
/*
* One-shot "repair" repaint, rescheduled by every interaction to fire 600ms
* after the last one.
*
* Redrawing is itself what glitches the panel: the flush's PSRAM writes
* contend with the LCD's scan-out, and a burst of interactive redraws is the
* most likely moment for the bounce ISR to lose (README, PSRAM bandwidth).
* The glitched lines then persist because LVGL believes those pixels are
* fine. One full repaint after the burst settles cleans them up within
* ~0.6s instead of leaving them until the next poll's repaint, up to 60s
* away. It runs once per burst, not periodically -- a constant repaint loop
* would be a constant supply of fresh glitch opportunities.
*/
static lv_timer_t *repair_timer;
static void repair_timer_cb(lv_timer_t *t)
{
lv_obj_invalidate(lv_screen_active());
lv_timer_pause(t);
}
static void schedule_repair(void)
{
if (repair_timer != NULL) {
lv_timer_reset(repair_timer);
lv_timer_resume(repair_timer);
}
}
/* --------------------------------------------------------- condition icons */
typedef struct {
const char *slug;
const char *icon;
uint32_t colour;
} cond_icon_t;
/* met.no slugs -> glyph + colour. Warm for sun, blue for water, dim for the
* grey stuff -- the forecast strip reads at a glance without the words. */
static const cond_icon_t COND_ICONS[] = {
{ "clear-night", ICON_W_NIGHT, COL_TEXT_DIM },
{ "cloudy", ICON_W_CLOUDY, COL_TEXT_DIM },
{ "exceptional", ICON_W_ALERT, 0xE74C3C },
{ "fog", ICON_W_FOG, COL_TEXT_DIM },
{ "hail", ICON_W_HAIL, COL_ACCENT },
{ "lightning", ICON_W_LIGHTNING, COL_ON },
{ "lightning-rainy", ICON_W_LIGHTNING_RAIN, COL_ON },
{ "partlycloudy", ICON_W_PARTLY, COL_ON },
{ "pouring", ICON_W_POURING, COL_ACCENT },
{ "rainy", ICON_W_RAINY, COL_ACCENT },
{ "snowy", ICON_W_SNOWY, COL_TEXT },
{ "snowy-rainy", ICON_W_SNOWY_RAIN, COL_ACCENT },
{ "sunny", ICON_W_SUNNY, COL_ON },
{ "windy", ICON_W_WINDY, COL_TEXT_DIM },
{ "windy-variant", ICON_W_WINDY, COL_TEXT_DIM },
};
/* Unknown slug -> alert glyph in warning red: visibly "add me to the table",
* matching how ha_condition_label() passes unknown slugs through as text. */
static const cond_icon_t COND_ICON_FALLBACK = { NULL, ICON_W_ALERT, 0xE74C3C };
static const cond_icon_t *cond_icon_lookup(const char *slug)
{
if (slug != NULL) {
for (size_t i = 0; i < sizeof(COND_ICONS) / sizeof(COND_ICONS[0]); i++) {
if (strcmp(COND_ICONS[i].slug, slug) == 0) {
return &COND_ICONS[i];
}
}
}
return &COND_ICON_FALLBACK;
}
/* ------------------------------------------------------------- primitives */
/*
* lv_label_set_text() invalidates unconditionally, even when the new text is
* identical -- which would repaint the whole dashboard every poll whether or
* not anything moved. Comparing first means an unchanged value costs nothing.
*/
static void label_set_if_changed(lv_obj_t *label, const char *text)
{
if (label == NULL || text == NULL) {
return;
}
const char *current = lv_label_get_text(label);
if (current != NULL && strcmp(current, text) == 0) {
return;
}
lv_label_set_text(label, text);
}
/* Formats a reading, or NO_VALUE when the sensor had nothing to say. */
static void format_value(const ha_value_t *v, const char *fmt, char *buf, size_t len)
{
if (v->valid) {
snprintf(buf, len, fmt, v->value);
} else {
snprintf(buf, len, "%s", NO_VALUE);
}
}
static lv_obj_t *panel_create(lv_obj_t *parent, int32_t w, int32_t h, uint32_t colour)
{
lv_obj_t *p = lv_obj_create(parent);
lv_obj_set_size(p, w, h);
lv_obj_set_style_bg_color(p, lv_color_hex(colour), LV_PART_MAIN);
lv_obj_set_style_border_width(p, 0, LV_PART_MAIN);
lv_obj_set_style_radius(p, 10, LV_PART_MAIN);
lv_obj_set_style_pad_all(p, 8, LV_PART_MAIN);
lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE);
return p;
}
static lv_obj_t *label_create(lv_obj_t *parent, const char *text,
const lv_font_t *font, uint32_t colour)
{
lv_obj_t *l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(colour), LV_PART_MAIN);
return l;
}
/* An icon is a one-glyph label in an icon font. With no font (tiny_ttf
* failed) the label is created empty, so layout stays put and nothing
* renders as a missing-glyph box. */
static lv_obj_t *icon_create(lv_obj_t *parent, const char *glyph,
lv_font_t *font, uint32_t colour)
{
lv_obj_t *l = lv_label_create(parent);
if (font != NULL) {
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
lv_label_set_text(l, glyph);
} else {
lv_label_set_text(l, "");
}
lv_obj_set_style_text_color(l, lv_color_hex(colour), LV_PART_MAIN);
return l;
}
/*
* Trend arrow: up warm, down blue, nothing for flat or unknown. "No arrow"
* covers both "steady" and "not enough history yet" -- a reading that is
* going nowhere and a reading we cannot judge should both look calm.
*/
static void trend_render(lv_obj_t *icon, ha_trend_t t)
{
if (icon == NULL || icon_font_16 == NULL) {
return;
}
switch (t) {
case HA_TREND_RISING:
label_set_if_changed(icon, ICON_TREND_UP);
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
break;
case HA_TREND_FALLING:
label_set_if_changed(icon, ICON_TREND_DOWN);
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
break;
default:
label_set_if_changed(icon, "");
break;
}
}
/* ------------------------------------------------------ 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);
}
schedule_repair();
}
/* ------------------------------------------------------------ light tiles */
static void light_tile_update(int idx, const ha_light_status_t *st);
/*
* Apply the state the user just requested, before Home Assistant has
* confirmed it. The tile flips at touch speed; the hold keeps the next
* poll's possibly-stale data from snapping it back. See light_hold_t.
*/
static void light_apply_optimistic(int idx, ha_light_status_t st)
{
light_hold[idx].active = true;
light_hold[idx].since = lv_tick_get();
light_hold[idx].expect = st.state;
last_light[idx] = st;
last_light_valid = true;
light_tile_update(idx, &st);
}
/*
* Both handlers run on the LVGL task with the lock held. They only forward to
* light_cb, which must not block -- see ui_manager.h.
*
* LV_EVENT_CLICKED fires on RELEASE (press + release inside the object), so a
* tap commits when the finger lifts, and a finger that slides off the tile
* commits nothing.
*/
static void light_tile_clicked(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
/* Unavailable entities are not toggleable; the tile is styled to show it,
* but a stray tap should still do nothing rather than send a doomed
* command. */
if (last_light_valid && last_light[idx].state == HA_LIGHT_UNAVAILABLE) {
return;
}
const bool turn_on = !(last_light_valid && last_light[idx].state == HA_LIGHT_ON);
/*
* Optimistic brightness: keep the last known level if we have one (HA
* restores the previous level on a plain turn_on), else -1 and the tile
* says "On" without a percentage until the poll fills it in.
*/
ha_light_status_t expect = {
.state = turn_on ? HA_LIGHT_ON : HA_LIGHT_OFF,
.brightness = (turn_on && last_light[idx].brightness > 0)
? last_light[idx].brightness
: -1,
};
light_apply_optimistic(idx, expect);
schedule_repair();
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
* the current brightness here would fight a user who dimmed the light
* elsewhere. */
light_cb(idx, turn_on, -1);
}
/* One command for a slider position. Dragging to zero means off, not "on at
* zero brightness" -- HA accepts brightness 0 but leaves the light on, which
* looks like a bug. */
static void light_send_pct(int idx, int32_t pct)
{
if (pct <= 0) {
light_apply_optimistic(idx, (ha_light_status_t){ HA_LIGHT_OFF, -1 });
light_cb(idx, false, -1);
} else {
const int16_t brightness = (int16_t)((pct * 255) / 100);
light_apply_optimistic(idx, (ha_light_status_t){ HA_LIGHT_ON, brightness });
light_cb(idx, true, brightness);
}
slider_sent_pct[idx] = pct;
}
/*
* Fires as the knob moves under a finger (programmatic set_value does not
* raise VALUE_CHANGED, so this is user input only). Two jobs:
*
* - keep the tile's readout live under the finger ("On 43%" tracks the
* drag), because the screen must follow the finger, never the light;
* - send ONE command at the first touch position, so the light comes on
* at the level the finger landed on without waiting for the lift.
*
* Everything between first touch and lift is display-only.
*/
static void light_slider_changed(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
lv_obj_t *slider = lv_event_get_target_obj(e);
const int32_t pct = lv_slider_get_value(slider);
char buf[24];
if (pct <= 0) {
snprintf(buf, sizeof(buf), "Off");
} else {
snprintf(buf, sizeof(buf), "On %d%%", (int)pct);
}
label_set_if_changed(light_state[idx], buf);
lv_obj_set_style_text_color(light_state[idx],
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
schedule_repair();
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
return;
}
if (!slider_touched[idx]) {
slider_touched[idx] = true;
light_send_pct(idx, pct);
}
}
/* Finger lifted: send the final position -- unless the finger never moved
* from where the press-down command already put it. */
static void light_slider_released(lv_event_t *e)
{
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
return;
}
lv_obj_t *slider = lv_event_get_target_obj(e);
const int32_t pct = lv_slider_get_value(slider);
const bool touched = slider_touched[idx];
slider_touched[idx] = false;
schedule_repair();
if (touched && pct == slider_sent_pct[idx]) {
return; /* tap without drag: the press command already said this */
}
light_send_pct(idx, pct);
}
static void light_tile_build(lv_obj_t *parent, int idx, int32_t w, int32_t h)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
lv_obj_t *tile = panel_create(parent, w, h, COL_PANEL);
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tile, light_tile_clicked, LV_EVENT_CLICKED,
(void *)(intptr_t)idx);
lv_obj_set_style_pad_all(tile, 10, LV_PART_MAIN);
light_tile[idx] = tile;
/*
* Explicit alignment rather than flex. A flex column packs the children
* against the top and leaves the rest of a 116px tile empty; the slider
* wants to sit at the bottom where a thumb reaches it.
*
* The icon gets its own row rather than sharing the name's. In a ~125px
* tile an icon beside the name leaves ~79px of text -- every room name
* truncated to "Main R...". Vertically there is space to burn;
* horizontally there is none.
*/
light_icon[idx] = icon_create(tile, LIGHT_ICONS[idx], icon_font_22, COL_TEXT_DIM);
lv_obj_align(light_icon[idx], LV_ALIGN_TOP_LEFT, 0, -2);
light_name[idx] = label_create(tile, cfg->label, &lv_font_montserrat_16, COL_TEXT);
lv_label_set_long_mode(light_name[idx], LV_LABEL_LONG_DOT);
/* The explicit one-line height is what actually forces truncation: with
* a content-sized height LV_LABEL_LONG_DOT wraps to a second line before
* it adds dots -- onto the state label below. */
lv_obj_set_size(light_name[idx], w - 20,
lv_font_get_line_height(&lv_font_montserrat_16));
lv_obj_align(light_name[idx], LV_ALIGN_TOP_LEFT, 0, 24);
light_state[idx] = label_create(tile, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
lv_obj_align(light_state[idx], LV_ALIGN_TOP_LEFT, 0, 46);
if (cfg->dimmable) {
lv_obj_t *s = lv_slider_create(tile);
lv_obj_set_width(s, LV_PCT(100));
lv_obj_set_height(s, 12);
lv_obj_align(s, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_slider_set_range(s, 0, 100);
/*
* The track needs real contrast against the tile. COL_PANEL_HI is
* only a shade off the tile background, which made an off light's
* slider look like a lone floating knob.
*/
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TRACK), LV_PART_MAIN);
lv_obj_set_style_bg_color(s, lv_color_hex(COL_ON), LV_PART_INDICATOR);
/* Dim knob, not white. A white knob on an off tile is the highest
* contrast thing in the row, so six dark tiles read as six bright
* dots -- the eye goes straight to the controls that are doing
* nothing. */
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TEXT_DIM), LV_PART_KNOB);
lv_obj_set_style_pad_all(s, 2, LV_PART_KNOB);
/*
* Two commands per touch, not per pixel: VALUE_CHANGED sends one at
* the first touch position (light responds immediately where the
* finger lands), RELEASED sends the final position. In between, the
* drag only updates the display -- and ui_update never writes to a
* pressed slider, so nothing moves the knob out from under the
* finger.
*/
lv_obj_add_event_cb(s, light_slider_changed, LV_EVENT_VALUE_CHANGED,
(void *)(intptr_t)idx);
lv_obj_add_event_cb(s, light_slider_released, LV_EVENT_RELEASED,
(void *)(intptr_t)idx);
light_slider[idx] = s;
} else {
/* The patio is a smart plug in the switch domain -- no brightness at
* all, so its tile gets a label where the slider would be rather than
* a dead gap that looks like a missing control. */
lv_obj_t *note = label_create(tile, "switch", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_align(note, LV_ALIGN_BOTTOM_LEFT, 0, 0);
light_slider[idx] = NULL;
}
}
static void light_tile_update(int idx, const ha_light_status_t *st)
{
const ha_light_cfg_t *cfg = &HA_LIGHTS[idx];
const bool on = (st->state == HA_LIGHT_ON);
const bool dead = (st->state == HA_LIGHT_UNAVAILABLE);
lv_obj_set_style_bg_color(light_tile[idx],
lv_color_hex(on ? COL_PANEL_HI : COL_PANEL),
LV_PART_MAIN);
lv_obj_set_style_border_width(light_tile[idx], on ? 2 : 0, LV_PART_MAIN);
lv_obj_set_style_border_color(light_tile[idx], lv_color_hex(COL_ON), LV_PART_MAIN);
lv_obj_set_style_text_color(light_name[idx],
lv_color_hex(dead ? COL_TEXT_FAINT : COL_TEXT),
LV_PART_MAIN);
/* The room icon is the strongest "which lights are on" signal in the
* band: amber when lit, dim when off, faint when unreachable. */
lv_obj_set_style_text_color(light_icon[idx],
lv_color_hex(on ? COL_ON
: (dead ? COL_TEXT_FAINT : COL_TEXT_DIM)),
LV_PART_MAIN);
char buf[24];
if (dead) {
snprintf(buf, sizeof(buf), "unavailable");
} else if (!on) {
snprintf(buf, sizeof(buf), "Off");
} else if (cfg->dimmable && st->brightness >= 0) {
/* HA's 0-255 shown as a percentage; +127 rounds to nearest so 255
* reads as 100%% and not 99%%. */
snprintf(buf, sizeof(buf), "On %d%%", (st->brightness * 100 + 127) / 255);
} else {
snprintf(buf, sizeof(buf), "On");
}
label_set_if_changed(light_state[idx], buf);
lv_obj_set_style_text_color(light_state[idx],
lv_color_hex(on ? COL_ON : COL_TEXT_DIM),
LV_PART_MAIN);
if (light_slider[idx] != NULL) {
const int32_t pct = (on && st->brightness >= 0)
? (st->brightness * 100 + 127) / 255
: 0;
/* Never move a knob a finger is holding -- the optimistic apply on
* press-down lands here while the drag is still in progress. */
if (!lv_obj_has_state(light_slider[idx], LV_STATE_PRESSED)) {
lv_slider_set_value(light_slider[idx], pct, LV_ANIM_OFF);
}
/* Bright knob only while the light is on, so the lit tiles are what
* draw the eye along the row. */
lv_obj_set_style_bg_color(light_slider[idx],
lv_color_hex(on ? COL_TEXT : COL_TEXT_DIM),
LV_PART_KNOB);
if (dead) {
lv_obj_add_state(light_slider[idx], LV_STATE_DISABLED);
} else {
lv_obj_remove_state(light_slider[idx], LV_STATE_DISABLED);
}
}
}
/*
* True while this tile must not be touched by poll data: a finger is on it,
* or an optimistic hold is active and unresolved.
*/
static bool light_tile_busy(int idx, const ha_light_status_t *incoming)
{
/* Never move anything under a finger. The slider check is the one that
* keeps the knob pinned where the user is holding it. */
if (lv_obj_has_state(light_tile[idx], LV_STATE_PRESSED)) {
return true;
}
if (light_slider[idx] != NULL &&
lv_obj_has_state(light_slider[idx], LV_STATE_PRESSED)) {
return true;
}
light_hold_t *hold = &light_hold[idx];
if (!hold->active) {
return false;
}
/* HA agrees with what the user asked for: the hold has done its job.
* Fall through so the poll's real brightness lands on the tile. */
if (incoming->state == hold->expect) {
hold->active = false;
return false;
}
/* Still disagreeing after the window: the command failed, HA wins. */
if (lv_tick_elaps(hold->since) >= OPTIMISTIC_HOLD_MS) {
hold->active = false;
return false;
}
return true; /* disagreement inside the window: keep the user's state */
}
/* ------------------------------------------------------------------ build */
static void build_header(lv_obj_t *scr)
{
lv_obj_t *bar = panel_create(scr, LV_PCT(100), BAND_HEADER_H, COL_PANEL);
lv_obj_set_style_radius(bar, 0, LV_PART_MAIN);
lv_obj_set_style_pad_hor(bar, 16, LV_PART_MAIN);
date_label = label_create(bar, "--", &lv_font_montserrat_20, COL_TEXT_DIM);
lv_obj_align(date_label, LV_ALIGN_LEFT_MID, 0, 0);
clock_label = label_create(bar, "--:--", &lv_font_montserrat_24, COL_TEXT);
lv_obj_align(clock_label, LV_ALIGN_CENTER, 0, 0);
/* Icon right-most; the text hangs off its left edge and is re-anchored
* on every update because its width changes with the words. */
cond_icon = icon_create(bar, ICON_W_CLOUDY, icon_font_26, COL_TEXT_DIM);
lv_obj_align(cond_icon, LV_ALIGN_RIGHT_MID, 0, 0);
cond_label = label_create(bar, "--", &lv_font_montserrat_20, COL_ACCENT);
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
}
static void build_body(lv_obj_t *scr)
{
lv_obj_t *row = lv_obj_create(scr);
lv_obj_set_size(row, LV_PCT(100), BAND_BODY_H);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
/* --- 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);
label_set_if_changed(fc_icon[i], ci->icon);
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
LV_PART_MAIN);
}
if (f->high_f.valid && f->low_f.valid) {
snprintf(buf, sizeof(buf), "%d / %d",
(int)lroundf(f->high_f.value), (int)lroundf(f->low_f.value));
} else if (f->high_f.valid) {
snprintf(buf, sizeof(buf), "%d", (int)lroundf(f->high_f.value));
} else {
snprintf(buf, sizeof(buf), NO_VALUE);
}
label_set_if_changed(fc_temp[i], buf);
}
}
void ui_update(const ha_data_t *data)
{
if (data == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped update: LVGL lock timed out");
return;
}
label_set_if_changed(clock_label, data->clock[0] ? data->clock : "--:--");
label_set_if_changed(date_label, data->date[0] ? data->date : "--");
label_set_if_changed(cond_label, ha_condition_label(data->condition));
if (icon_font_26 != NULL) {
const cond_icon_t *ci = cond_icon_lookup(
data->condition[0] ? data->condition : NULL);
label_set_if_changed(cond_icon, ci->icon);
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
}
/* The label's width changed with its words; re-hang it off the icon. */
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
update_body(data);
update_forecast(data);
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
/* A finger on the tile, or an optimistic hold still waiting for HA
* to catch up: poll data keeps its hands off. */
if (light_tile_busy(i, &data->lights[i])) {
continue;
}
/* Restyling a tile is many style writes; skip the ones that did not
* move. */
if (last_light_valid &&
last_light[i].state == data->lights[i].state &&
last_light[i].brightness == data->lights[i].brightness) {
continue;
}
light_tile_update(i, &data->lights[i]);
last_light[i] = data->lights[i];
}
last_light_valid = true;
lvgl_port_unlock();
}
void ui_set_stale(bool stale)
{
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
return;
}
if (temp_label != NULL) {
/* Unlike lv_label_set_text(), setting an identical style value does
* not invalidate, so this is safe to call on every poll. */
lv_obj_set_style_text_color(temp_label,
lv_color_hex(stale ? COL_STALE : COL_TEXT),
LV_PART_MAIN);
}
lvgl_port_unlock();
}
void ui_set_status(const char *status)
{
if (status == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped status update: LVGL lock timed out");
return;
}
label_set_if_changed(status_label, status);
lvgl_port_unlock();
}
void ui_set_light_cb(ui_light_cmd_cb_t cb)
{
light_cb = cb;
}
void ui_set_therm_cb(ui_therm_cmd_cb_t cb)
{
therm_cb = cb;
}
void ui_update_thermostat(const therm_status_t *st)
{
if (st == NULL) {
return;
}
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out");
return;
}
char buf[32];
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f));
label_set_if_changed(setpoint_label, buf);
thermostat_state_text(st, buf, sizeof(buf));
label_set_if_changed(therm_state_label, buf);
/*
* The dot colour is the at-a-glance channel: amber means the burner is
* lit, red means something is wrong and no heat is coming, dim means
* everything is fine and there is nothing to do.
*/
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);
const bool heat_mode = (st->mode == THERM_MODE_HEAT);
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);
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);
/* Setpoint 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);
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);
/* Created paused; interactions reset-and-resume it. See schedule_repair. */
repair_timer = lv_timer_create(repair_timer_cb, 600, NULL);
if (repair_timer != NULL) {
lv_timer_pause(repair_timer);
}
build_header(scr);
build_body(scr);
build_lights(scr);
build_forecast(scr);
/* Its own band, accounted for in the BAND_* budget. Usually says
* something dull about WiFi; when a poll starts failing it is the only
* place that says so. */
status_label = label_create(scr, "Starting...", &lv_font_montserrat_12, COL_TEXT_FAINT);
lv_obj_set_height(status_label, BAND_STATUS_H);
lv_obj_set_width(status_label, LV_PCT(100));
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lvgl_port_unlock();
ESP_LOGI(TAG, "UI initialized (%d lights, %d forecast days, icons %s)",
HA_LIGHT_COUNT, HA_FORECAST_DAYS,
icon_font_22 != NULL ? "on" : "OFF");
}