New panel design
This commit is contained in:
1 parent
face2432e4
commit
b5d6b4a897
30 files changed
+4540
-171
No files matched your search
+602
-96
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* Dashboard layout for the 800x480 panel.
|
||||
*
|
||||
* Five horizontal bands, sized to fill exactly one screen with no scrolling:
|
||||
*
|
||||
* header 42px date, clock, current condition
|
||||
* body 182px outdoor temperature card + a 3x2 grid of readings
|
||||
* lights 116px one tile per HA_LIGHTS entry
|
||||
* forecast 86px five day columns
|
||||
* status 14px WiFi / poll failures
|
||||
*
|
||||
* Nothing is hidden behind navigation: this is a wall panel that has to be
|
||||
* readable from across the room at a glance.
|
||||
*
|
||||
* 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>
|
||||
|
||||
@@ -12,24 +31,101 @@ static const char *TAG = "ui_manager";
|
||||
#define UI_LOCK_TIMEOUT_MS 1000
|
||||
|
||||
/*
|
||||
* Bring-up diagnostic: a live readout of the last touch coordinate. Off by
|
||||
* default -- define UI_DEBUG_TOUCH to put it back on screen when working on
|
||||
* the input path.
|
||||
* 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 UI_DEBUG_TOUCH */
|
||||
#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
|
||||
|
||||
static lv_obj_t *temp_label = NULL;
|
||||
static lv_obj_t *cond_label = NULL;
|
||||
static lv_obj_t *status_label = NULL;
|
||||
#ifdef UI_DEBUG_TOUCH
|
||||
static lv_obj_t *touch_label = NULL;
|
||||
#endif
|
||||
/* 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 */
|
||||
|
||||
/*
|
||||
* lv_label_set_text() invalidates the label unconditionally, even when the
|
||||
* new text is identical -- which would repaint the temperature every poll
|
||||
* whether or not it moved. Comparing first means an unchanged value costs
|
||||
* nothing: no invalidation, so no redraw and no flush.
|
||||
* 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 "--"
|
||||
|
||||
static lv_obj_t *clock_label;
|
||||
static lv_obj_t *date_label;
|
||||
static lv_obj_t *cond_label;
|
||||
|
||||
static lv_obj_t *temp_label;
|
||||
static lv_obj_t *temp_unit_label;
|
||||
static lv_obj_t *outdoor_detail_label;
|
||||
|
||||
/* 3x2 reading grid: value and caption per cell. */
|
||||
enum {
|
||||
CELL_INDOOR = 0,
|
||||
CELL_WIND,
|
||||
CELL_RAIN,
|
||||
CELL_SUNRISE,
|
||||
CELL_SUNSET,
|
||||
CELL_PRESSURE,
|
||||
CELL_COUNT,
|
||||
};
|
||||
static lv_obj_t *cell_value[CELL_COUNT];
|
||||
static lv_obj_t *cell_sub[CELL_COUNT];
|
||||
|
||||
static lv_obj_t *light_tile[HA_LIGHT_COUNT];
|
||||
static lv_obj_t *light_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_cond[HA_FORECAST_DAYS];
|
||||
static lv_obj_t *fc_temp[HA_FORECAST_DAYS];
|
||||
|
||||
static lv_obj_t *status_label;
|
||||
|
||||
static ui_light_cmd_cb_t light_cb;
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/* ------------------------------------------------------------- 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)
|
||||
{
|
||||
@@ -45,52 +141,489 @@ static void label_set_if_changed(lv_obj_t *label, const char *text)
|
||||
lv_label_set_text(label, text);
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE, even with the readout compiled out: LVGL does not bubble events to
|
||||
* parents by default. An event handler on the screen object never fires for
|
||||
* taps landing on a child widget -- the header or the temperature card
|
||||
* consume them -- which presents as whole regions of the panel being dead to
|
||||
* touch while the hardware reports them perfectly well. Anything that needs
|
||||
* to observe touches screen-wide must poll the input device, as below, rather
|
||||
* than hang an event handler on the screen.
|
||||
*/
|
||||
#ifdef UI_DEBUG_TOUCH
|
||||
static void touch_readout_timer_cb(lv_timer_t *timer)
|
||||
/* 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)
|
||||
{
|
||||
lv_indev_t *indev = lv_indev_get_next(NULL);
|
||||
if (indev == NULL || touch_label == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
lv_point_t p;
|
||||
lv_indev_get_point(indev, &p);
|
||||
|
||||
char buf[48];
|
||||
if (lv_indev_get_state(indev) == LV_INDEV_STATE_PRESSED) {
|
||||
snprintf(buf, sizeof(buf), "touch %d, %d", (int)p.x, (int)p.y);
|
||||
if (v->valid) {
|
||||
snprintf(buf, len, fmt, v->value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "touch %d, %d (released)", (int)p.x, (int)p.y);
|
||||
snprintf(buf, len, "%s", NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(touch_label, buf);
|
||||
}
|
||||
#endif
|
||||
|
||||
void ui_update_weather(int16_t temp_f, const char *condition)
|
||||
static lv_obj_t *panel_create(lv_obj_t *parent, int32_t w, int32_t h, uint32_t colour)
|
||||
{
|
||||
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
||||
ESP_LOGW(TAG, "Dropped weather update: LVGL lock timed out");
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ light tiles */
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "%d°F", temp_f);
|
||||
/* 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);
|
||||
|
||||
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
|
||||
* the current brightness here would fight a user who dimmed the light
|
||||
* elsewhere. */
|
||||
light_cb(idx, turn_on, -1);
|
||||
}
|
||||
|
||||
static void light_slider_changed(lv_event_t *e)
|
||||
{
|
||||
const int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
if (light_cb == NULL || idx < 0 || idx >= HA_LIGHT_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
lv_obj_t *slider = lv_event_get_target_obj(e);
|
||||
const int32_t pct = lv_slider_get_value(slider);
|
||||
|
||||
/* Dragging to zero means off, not "on at zero brightness" -- HA accepts
|
||||
* brightness 0 but leaves the light on, which looks like a bug. */
|
||||
if (pct <= 0) {
|
||||
light_cb(idx, false, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
light_cb(idx, true, (int16_t)((pct * 255) / 100));
|
||||
}
|
||||
|
||||
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 three
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
lv_obj_set_width(light_name[idx], LV_PCT(100));
|
||||
/*
|
||||
* An explicit one-line height is what actually forces truncation. With a
|
||||
* content-sized height LV_LABEL_LONG_DOT has room to grow, so it wraps to
|
||||
* a second line first and only then adds dots -- and that second line
|
||||
* lands on top of the state label below.
|
||||
*/
|
||||
lv_obj_set_height(light_name[idx], lv_font_get_line_height(&lv_font_montserrat_16));
|
||||
lv_obj_align(light_name[idx], LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
|
||||
light_state[idx] = label_create(tile, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||
lv_obj_align(light_state[idx], LV_ALIGN_TOP_LEFT, 0, 24);
|
||||
|
||||
if (cfg->dimmable) {
|
||||
lv_obj_t *s = lv_slider_create(tile);
|
||||
lv_obj_set_width(s, LV_PCT(100));
|
||||
lv_obj_set_height(s, 12);
|
||||
lv_obj_align(s, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||
lv_slider_set_range(s, 0, 100);
|
||||
|
||||
/*
|
||||
* The track needs real contrast against the tile. COL_PANEL_HI is
|
||||
* only a shade off the tile background, which made an off light's
|
||||
* slider look like a lone floating knob.
|
||||
*/
|
||||
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TRACK), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(s, lv_color_hex(COL_ON), LV_PART_INDICATOR);
|
||||
|
||||
/* Dim knob, not white. A white knob on an off tile is the highest
|
||||
* contrast thing in the row, so six dark tiles read as six bright
|
||||
* dots -- the eye goes straight to the controls that are doing
|
||||
* nothing. */
|
||||
lv_obj_set_style_bg_color(s, lv_color_hex(COL_TEXT_DIM), LV_PART_KNOB);
|
||||
lv_obj_set_style_pad_all(s, 2, LV_PART_KNOB);
|
||||
|
||||
/* RELEASED, not VALUE_CHANGED: the latter fires continuously while a
|
||||
* finger drags, which would queue a command per pixel. */
|
||||
lv_obj_add_event_cb(s, light_slider_changed, 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);
|
||||
|
||||
char buf[24];
|
||||
if (dead) {
|
||||
snprintf(buf, sizeof(buf), "unavailable");
|
||||
} else if (!on) {
|
||||
snprintf(buf, sizeof(buf), "Off");
|
||||
} else if (cfg->dimmable && st->brightness >= 0) {
|
||||
/* HA's 0-255 shown as a percentage; +127 rounds to nearest so 255
|
||||
* reads as 100%% and not 99%%. */
|
||||
snprintf(buf, sizeof(buf), "On %d%%", (st->brightness * 100 + 127) / 255);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "On");
|
||||
}
|
||||
label_set_if_changed(light_state[idx], buf);
|
||||
lv_obj_set_style_text_color(light_state[idx],
|
||||
lv_color_hex(on ? COL_ON : COL_TEXT_DIM),
|
||||
LV_PART_MAIN);
|
||||
|
||||
if (light_slider[idx] != NULL) {
|
||||
const int32_t pct = (on && st->brightness >= 0)
|
||||
? (st->brightness * 100 + 127) / 255
|
||||
: 0;
|
||||
lv_slider_set_value(light_slider[idx], pct, LV_ANIM_OFF);
|
||||
|
||||
/* Bright knob only while the light is on, so the lit tiles are what
|
||||
* draw the eye along the row. */
|
||||
lv_obj_set_style_bg_color(light_slider[idx],
|
||||
lv_color_hex(on ? COL_TEXT : COL_TEXT_DIM),
|
||||
LV_PART_KNOB);
|
||||
|
||||
if (dead) {
|
||||
lv_obj_add_state(light_slider[idx], LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_remove_state(light_slider[idx], LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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);
|
||||
|
||||
cond_label = label_create(bar, "--", &lv_font_montserrat_20, COL_ACCENT);
|
||||
lv_obj_align(cond_label, LV_ALIGN_RIGHT_MID, 0, 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);
|
||||
|
||||
/* --- outdoor card --- */
|
||||
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *heading = label_create(card, "OUTDOOR", &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);
|
||||
|
||||
/* Centred vertically rather than pinned under the heading: the card is
|
||||
* 182px and a 48px number at the top leaves an obvious hole in the middle
|
||||
* of the most important element on the screen. */
|
||||
temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT);
|
||||
lv_obj_align(temp_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
/* Degree sign as its own label so it can sit small and high beside a
|
||||
* 48px number instead of scaling with it. */
|
||||
temp_unit_label = label_create(card, "°F", &lv_font_montserrat_20, COL_TEXT_DIM);
|
||||
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
|
||||
|
||||
outdoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM);
|
||||
lv_obj_align(outdoor_detail_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
|
||||
/* --- 3x2 reading grid --- */
|
||||
lv_obj_t *grid = lv_obj_create(row);
|
||||
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(grid, 1);
|
||||
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(grid, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(grid, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_gap(grid, BAND_GAP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
||||
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
static const char *const captions[CELL_COUNT] = {
|
||||
"INDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE",
|
||||
};
|
||||
|
||||
/* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the
|
||||
* wrap puts a third row below the fold. */
|
||||
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
|
||||
|
||||
for (int i = 0; i < CELL_COUNT; i++) {
|
||||
lv_obj_t *cell = panel_create(grid, 156, cell_h, COL_PANEL);
|
||||
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
|
||||
lv_obj_align(cap, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
|
||||
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_24, COL_TEXT);
|
||||
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
|
||||
|
||||
cell_sub[i] = label_create(cell, "", &lv_font_montserrat_12, COL_TEXT_DIM);
|
||||
lv_obj_align(cell_sub[i], LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void build_lights(lv_obj_t *scr)
|
||||
{
|
||||
lv_obj_t *row = lv_obj_create(scr);
|
||||
lv_obj_set_size(row, LV_PCT(100), BAND_LIGHTS_H);
|
||||
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
const int32_t tile_w = (800 - 2 * SCREEN_PAD - (HA_LIGHT_COUNT - 1) * BAND_GAP)
|
||||
/ HA_LIGHT_COUNT;
|
||||
|
||||
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||
light_tile_build(row, i, tile_w, LV_PCT(100));
|
||||
}
|
||||
}
|
||||
|
||||
static void build_forecast(lv_obj_t *scr)
|
||||
{
|
||||
lv_obj_t *row = lv_obj_create(scr);
|
||||
lv_obj_set_size(row, LV_PCT(100), BAND_FORECAST_H);
|
||||
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(row, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_column(row, BAND_GAP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
const int32_t col_w = (800 - 2 * SCREEN_PAD - (HA_FORECAST_DAYS - 1) * BAND_GAP)
|
||||
/ HA_FORECAST_DAYS;
|
||||
|
||||
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
|
||||
lv_obj_t *col = panel_create(row, col_w, LV_PCT(100), COL_PANEL);
|
||||
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(col, 2, LV_PART_MAIN);
|
||||
|
||||
fc_day[i] = label_create(col, "--", &lv_font_montserrat_16, COL_TEXT);
|
||||
fc_cond[i] = label_create(col, "--", &lv_font_montserrat_12, 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];
|
||||
|
||||
/* Outdoor card. Rounded with lroundf, not truncation: below 0 degF a cast
|
||||
* rounds toward zero and turns -10.6 into -10. */
|
||||
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(temp_label, buf);
|
||||
label_set_if_changed(cond_label, condition);
|
||||
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
|
||||
|
||||
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.0f%% humidity dew %.0f°",
|
||||
d->outdoor_humidity.value, d->dew_point_f.value);
|
||||
} else if (d->outdoor_humidity.valid) {
|
||||
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->outdoor_humidity.value);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||
}
|
||||
label_set_if_changed(outdoor_detail_label, buf);
|
||||
|
||||
/* Indoor */
|
||||
format_value(&d->indoor_temp_f, "%.1f°", buf, sizeof(buf));
|
||||
label_set_if_changed(cell_value[CELL_INDOOR], buf);
|
||||
if (d->indoor_humidity.valid) {
|
||||
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->indoor_humidity.value);
|
||||
} else {
|
||||
sub[0] = '\0';
|
||||
}
|
||||
label_set_if_changed(cell_sub[CELL_INDOOR], sub);
|
||||
|
||||
/*
|
||||
* Wind. The station anemometer has been offline since 2026-07-27, so this
|
||||
* cell is expected to read "--" until the hardware is fixed. It is
|
||||
* deliberately NOT backfilled from the met.no forecast: showing regional
|
||||
* modelled wind under a heading that means "my yard" is worse than
|
||||
* showing nothing.
|
||||
*/
|
||||
format_value(&d->wind_mph, "%.0f", buf, sizeof(buf));
|
||||
if (d->wind_mph.valid) {
|
||||
const size_t n = strlen(buf);
|
||||
snprintf(buf + n, sizeof(buf) - n, " mph");
|
||||
}
|
||||
label_set_if_changed(cell_value[CELL_WIND], buf);
|
||||
|
||||
if (d->wind_dir_deg.valid && d->gust_mph.valid) {
|
||||
snprintf(sub, sizeof(sub), "%s gust %.0f",
|
||||
ha_wind_cardinal(d->wind_dir_deg.value), d->gust_mph.value);
|
||||
} else if (d->wind_dir_deg.valid) {
|
||||
snprintf(sub, sizeof(sub), "%s", ha_wind_cardinal(d->wind_dir_deg.value));
|
||||
} else {
|
||||
snprintf(sub, sizeof(sub), "sensor offline");
|
||||
}
|
||||
label_set_if_changed(cell_sub[CELL_WIND], sub);
|
||||
|
||||
/* Rain */
|
||||
format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf));
|
||||
label_set_if_changed(cell_value[CELL_RAIN], buf);
|
||||
if (d->rain_rate_inhr.valid && d->rain_rate_inhr.value > 0.0f) {
|
||||
snprintf(sub, sizeof(sub), "%.2f in/h now", d->rain_rate_inhr.value);
|
||||
} else {
|
||||
snprintf(sub, sizeof(sub), "not raining");
|
||||
}
|
||||
label_set_if_changed(cell_sub[CELL_RAIN], sub);
|
||||
|
||||
/* Sun. These strings are already local time from HA -- the board has no
|
||||
* clock of its own. */
|
||||
label_set_if_changed(cell_value[CELL_SUNRISE],
|
||||
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
||||
label_set_if_changed(cell_sub[CELL_SUNRISE], d->sun_up ? "up now" : "before dawn");
|
||||
|
||||
label_set_if_changed(cell_value[CELL_SUNSET],
|
||||
d->sunset[0] ? d->sunset : NO_VALUE);
|
||||
label_set_if_changed(cell_sub[CELL_SUNSET], d->sun_up ? "still up" : "down");
|
||||
|
||||
/* Pressure */
|
||||
format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf));
|
||||
label_set_if_changed(cell_value[CELL_PRESSURE], buf);
|
||||
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
|
||||
}
|
||||
|
||||
static void update_forecast(const ha_data_t *d)
|
||||
{
|
||||
char buf[32];
|
||||
|
||||
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
|
||||
if (i >= d->forecast_count) {
|
||||
label_set_if_changed(fc_day[i], "--");
|
||||
label_set_if_changed(fc_cond[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);
|
||||
label_set_if_changed(fc_cond[i], ha_condition_short(f->condition));
|
||||
|
||||
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));
|
||||
|
||||
update_body(data);
|
||||
update_forecast(data);
|
||||
|
||||
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||
/* 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_weather_stale(bool stale)
|
||||
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");
|
||||
@@ -101,7 +634,7 @@ void ui_set_weather_stale(bool stale)
|
||||
/* 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,
|
||||
stale ? lv_color_hex(0x5A6672) : lv_color_white(),
|
||||
lv_color_hex(stale ? COL_STALE : COL_TEXT),
|
||||
LV_PART_MAIN);
|
||||
}
|
||||
|
||||
@@ -124,6 +657,11 @@ void ui_set_status(const char *status)
|
||||
lvgl_port_unlock();
|
||||
}
|
||||
|
||||
void ui_set_light_cb(ui_light_cmd_cb_t cb)
|
||||
{
|
||||
light_cb = cb;
|
||||
}
|
||||
|
||||
void ui_init(void)
|
||||
{
|
||||
/* ui_init() runs from app_main, not the LVGL task, so it needs the lock
|
||||
@@ -134,59 +672,27 @@ void ui_init(void)
|
||||
}
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A2332), LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN);
|
||||
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_set_flex_align(scr, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t *header = lv_obj_create(scr);
|
||||
lv_obj_set_size(header, LV_PCT(100), 64);
|
||||
lv_obj_set_style_radius(header, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(header, lv_color_hex(0x0D1620), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(header, 0, LV_PART_MAIN);
|
||||
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
|
||||
build_header(scr);
|
||||
build_body(scr);
|
||||
build_lights(scr);
|
||||
build_forecast(scr);
|
||||
|
||||
lv_obj_t *title = lv_label_create(header);
|
||||
lv_label_set_text(title, "Thermostat");
|
||||
lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_28, LV_PART_MAIN);
|
||||
lv_obj_center(title);
|
||||
|
||||
lv_obj_t *card = lv_obj_create(scr);
|
||||
lv_obj_set_size(card, LV_PCT(80), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x2C3E50), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(card, 2, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x3498DB), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(card, 12, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(card, 24, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
temp_label = lv_label_create(card);
|
||||
lv_label_set_text(temp_label, "--°F");
|
||||
lv_obj_set_style_text_color(temp_label, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, LV_PART_MAIN);
|
||||
|
||||
cond_label = lv_label_create(card);
|
||||
lv_label_set_text(cond_label, "Waiting for data...");
|
||||
lv_obj_set_style_text_color(cond_label, lv_color_hex(0xBDC3C7), LV_PART_MAIN);
|
||||
|
||||
status_label = lv_label_create(scr);
|
||||
lv_label_set_text(status_label, "Starting...");
|
||||
lv_obj_set_style_text_color(status_label, lv_color_hex(0x7F8C8D), LV_PART_MAIN);
|
||||
|
||||
#ifdef UI_DEBUG_TOUCH
|
||||
touch_label = lv_label_create(scr);
|
||||
lv_label_set_text(touch_label, "touch --, --");
|
||||
lv_obj_set_style_text_color(touch_label, lv_color_hex(0x3498DB), LV_PART_MAIN);
|
||||
|
||||
lv_timer_create(touch_readout_timer_cb, 100, NULL);
|
||||
#endif
|
||||
/* 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");
|
||||
ESP_LOGI(TAG, "UI initialized (%d lights, %d forecast days)",
|
||||
HA_LIGHT_COUNT, HA_FORECAST_DAYS);
|
||||
}
|
||||
Reference in new issue
Block a user