Make page swipes smooth and give lights a new colour card (0.2.42)

Page switches show the new page as empty cards at once and draw two cards per
LVGL refresh, so touch is polled between the steps; on the Guition the longest
loop gap during a swipe dropped from 276 to 74 ms (docs/SWIPE_PROFILE.md).
Custom parts and control panels are reused instead of rebuilt, state updates
and ticks redraw only their own card, clocks redraw once a minute, and a swipe
past the last page draws nothing.

Slider handles sit inside the fill (grey when the card is off), the busy sheet
covers the whole card, and the light colour card is redesigned with rounded
tracks and colour knobs. Firmware 0.2.36; SWIPE_PROFILE builds carry a
per-swipe profiler for measurements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 10:59:02 +02:00
parent 8f52b6cb2d
commit 0ebb23a1f4
13 changed files with 856 additions and 245 deletions
+95 -29
View File
@@ -67,14 +67,24 @@ inline void subscribe(const char *const *entities, size_t count) {
(void) count;
#endif
}
// Temperature knob colour: the same warm-to-cool blend as the track under it.
inline lv_color_t kelvin_color(int value, int low, int high) {
int mix = high > low ? clamp((value - low) * 255 / (high - low), 0, 255) : 128;
return lv_color_mix(lv_color_hex(0xC6E6FF), lv_color_hex(0xFF9C32), mix);
}
inline void preview(Row &row) {
int value = lv_slider_get_value(row.slider);
if (row.index == 0) {
lv_label_set_text_fmt(row.value, "%d°", value);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0x111111), LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hsv_to_rgb(clamp(value, 0, 360) % 360, 100, 100), LV_PART_KNOB);
} else if (row.index == 1) {
lv_label_set_text_fmt(row.value, "%d K", value);
} else lv_label_set_text_fmt(row.value, "%d %%", value);
lv_obj_set_style_bg_color(row.slider, kelvin_color(value, lv_slider_get_min_value(row.slider), lv_slider_get_max_value(row.slider)), LV_PART_KNOB);
} else {
// The range reaches below 1 only to draw the round end that holds the handle at 1 %.
if (value < 1) { lv_slider_set_value(row.slider, 1, LV_ANIM_OFF); value = 1; }
lv_label_set_text_fmt(row.value, "%d %%", value);
}
}
inline void event(lv_event_t *e) {
auto &row = *static_cast<Row *>(lv_event_get_user_data(e));
@@ -90,6 +100,7 @@ inline void event(lv_event_t *e) {
value = clamp(value, active->minimum, active->maximum);
active->kelvin = value;
}
if (row.index == 2) value = clamp(value, 1, 100);
if (!demo && commits[row.index]) commits[row.index](value);
}
}
@@ -100,47 +111,102 @@ inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h);
return obj;
}
inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) {
inline lv_obj_t *round_end(lv_obj_t *parent, int x, int y, int size, uint32_t color) {
auto *end = plain(parent, x, y, size, size);
lv_obj_set_style_radius(end, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(end, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(end, lv_color_hex(color), 0);
return end;
}
// Three white cards like the tiles: an icon, the name and the value, then a pill-shaped track.
// Colour and temperature are gradient pills with a round knob in the chosen colour; brightness
// is filled like the tile sliders, with the white handle inside the fill.
inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height, const lv_font_t *icon_font = nullptr) {
if (ready) return;
ready = true;
bool large = width >= 480;
top = large ? 94 : 55; spacing = large ? 110 : 58;
int margin = large ? 24 : 16, w = width - 2 * margin;
int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14;
int track_w = w - 28;
top = large ? 100 : 52; spacing = large ? 122 : 60;
int margin = large ? 20 : 12, w = width - 2 * margin, card_h = spacing - (large ? 12 : 5);
int inset = large ? 18 : 10, text_y = large ? 14 : 5, track_h = large ? 32 : 18;
int track_x = inset, track_w = w - 2 * inset, track_y = card_h - inset + (large ? 2 : 3) - track_h, radius = track_h / 2;
const char *names[] = {"Color", "Color temperature", "Brightness"};
const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"};
const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, spacing - 2);
row.box = plain(parent, margin, top + i * spacing, w, card_h);
lv_obj_set_style_bg_opa(row.box, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(row.box, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_radius(row.box, large ? 18 : 10, 0);
lv_obj_set_style_border_width(row.box, 1, 0);
lv_obj_set_style_border_color(row.box, lv_color_hex(0xDDDDDD), 0);
lv_obj_set_style_text_font(row.box, font, 0);
lv_obj_set_style_text_color(row.box, lv_color_hex(0x1B1B1B), 0);
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, 2, 0);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0);
unsigned segments = i == 0 ? 6 : 1;
for (unsigned n = 0; n < segments; ++n) {
int start = n * track_w / segments, end = (n + 1) * track_w / segments;
auto *stripe = plain(row.box, track_x + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : i == 1 ? 0xFF9C32 : 0x40404A), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : i == 1 ? 0xC6E6FF : 0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
int text_x = inset;
if (icon_font) {
auto *icon = lv_label_create(row.box); lv_label_set_text(icon, icons[i]);
lv_obj_set_style_text_font(icon, icon_font, 0);
lv_obj_set_style_text_color(icon, lv_color_hex(0x616161), 0);
int icon_h = lv_font_get_line_height(icon_font), text_h = lv_font_get_line_height(font);
lv_obj_set_pos(icon, inset - (large ? 2 : 1), text_y + (text_h - icon_h) / 2);
text_x += icon_h + (large ? 6 : 4);
}
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, text_x, text_y);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -inset, text_y);
lv_obj_set_style_text_color(row.value, lv_color_hex(0x616161), 0);
row.slider = lv_slider_create(row.box);
lv_obj_remove_style_all(row.slider);
lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0x111111), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0x111111), LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100);
if (i < 2) {
// The knob travels between the centres of the two round ends.
round_end(row.box, track_x, track_y, track_h, i == 0 ? rainbow[0] : 0xFF9C32);
round_end(row.box, track_x + track_w - track_h, track_y, track_h, i == 0 ? rainbow[6] : 0xC6E6FF);
unsigned segments = i == 0 ? 6 : 1;
int span = track_w - track_h;
for (unsigned n = 0; n < segments; ++n) {
int start = n * span / segments, end = (n + 1) * span / segments;
auto *stripe = plain(row.box, track_x + radius + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : 0xFF9C32), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : 0xC6E6FF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
}
lv_obj_move_foreground(row.slider);
lv_obj_set_pos(row.slider, track_x + radius, track_y); lv_obj_set_size(row.slider, span, track_h);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 4 : 3, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, large ? 4 : 3, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_outline_width(row.slider, 1, LV_PART_KNOB);
lv_obj_set_style_outline_color(row.slider, lv_color_hex(0x000000), LV_PART_KNOB);
lv_obj_set_style_outline_opa(row.slider, LV_OPA_20, LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : 2000, i == 0 ? 360 : 6500);
} else {
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFDEFC3), LV_PART_MAIN);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFC107), LV_PART_INDICATOR);
// White handle inside the rounded end of the fill, as on the tiles (runtime_tiles::slider_handle).
int handle = large ? 6 : 4, back = track_h / 4 + handle / 2, half = track_h >> 1;
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_pad_left(row.slider, back + handle / 2 - half, LV_PART_KNOB);
lv_obj_set_style_pad_right(row.slider, handle / 2 - back - (track_h - half), LV_PART_KNOB);
lv_obj_set_style_pad_top(row.slider, -(track_h / 4), LV_PART_KNOB);
lv_obj_set_style_pad_bottom(row.slider, -(track_h / 4), LV_PART_KNOB);
// 1 % still shows one round end: the range starts that far below 1.
int below = track_w > track_h ? 99 * track_h / (track_w - track_h) : 0;
lv_slider_set_range(row.slider, 1 - below, 100);
}
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row);
}
+421 -206
View File
@@ -8,6 +8,7 @@
#include "cyd_ui.h"
#include "light_controls.h"
#include "tile_controls.h"
#include "swipe_profile.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h"
@@ -43,6 +44,12 @@ inline void render_header();
inline std::function<esphome::ESPTime()> now_time;
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; }
inline void tick();
inline void refresh_tile(size_t index);
#ifdef SWIPE_PROFILE
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning);
#endif
inline void refresh_header_only();
inline void refresh_all();
inline void refresh_detail(unsigned index);
inline const char *weather_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition);
@@ -104,6 +111,8 @@ struct Widgets {
lv_color_t panel_accent{}, panel_text{};
// Busy sheet: a translucent white cover with a small spinner while a command is under way.
lv_obj_t *busy{}, *spinner{};
// Page fill skeleton: a sheet in the card's colour over its contents until the card is drawn.
lv_obj_t *veil{};
};
constexpr unsigned POINT_BUFFER = 128;
inline std::array<Widgets, 10> widgets;
@@ -203,7 +212,7 @@ inline std::string receive(const std::string &payload) {
layout_rev = string(root["rev"], 16);
last_received = esphome::millis();
if (layout_changed) layout_changed();
if (refresh) refresh();
refresh_all();
// A repeat of the same layout keeps the inbox state as it is: no new recorder row.
result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles";
return true;
@@ -241,11 +250,20 @@ inline std::string receive(const std::string &payload) {
for (size_t i = 0; same && i < next.count; ++i) same = header.items[i] == next.items[i];
header = next;
last_received = esphome::millis();
if (!same && refresh) refresh();
if (!same) refresh_header_only();
// The same status as a tile state, so a changing value never flips the inbox entity.
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
#ifdef SWIPE_PROFILE
if (op == "swipe_test") {
// Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back`
// (ms) swipes straight back after each one, like a quick second swipe.
swipe_test(root["n"] | 20u, root["ms"] | 1200u, root["back"] | 0u, root);
result = "Swipe test started";
return true;
}
#endif
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
@@ -351,7 +369,7 @@ inline std::string receive(const std::string &payload) {
for(auto &w:widgets)if(w.index==index)w.cached_active=-1;
last_received = esphome::millis();
if (repack && layout_changed) layout_changed();
if (refresh) refresh();
refresh_tile(index);
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
refresh_detail(index);
result = model.ready() ? "Synced" : "Loading tiles";
@@ -369,10 +387,9 @@ inline void action(const std::string &service, const std::string &entity, const
entry.value = esphome::StringRef(entity);
request.data.push_back(entry);
if(!key.empty()) {esphome::api::HomeassistantServiceMap param;param.key=esphome::StringRef(key);param.value=esphome::StringRef(value);request.data.push_back(param);}
for(auto &tile:model.tiles) if(tile.entity==entity)tile.begin(esphome::millis());
for(size_t i=0;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());refresh_tile(i);}
esphome::api::global_api_server->send_homeassistant_action(request);
ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),entity.c_str());
if(refresh)refresh();
}
inline void setting_event(const std::string &key, int value) {
if(inbox.empty())return;
@@ -442,6 +459,8 @@ inline void commit_slider(unsigned i,int raw){
inline void slider_event(lv_event_t *e){
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e);
if(code==LV_EVENT_PRESSED){captured_slider=slider;slider_changed=false;}
// The range reaches below zero only to draw the round end at 0 (slider_handle).
if(code==LV_EVENT_VALUE_CHANGED && lv_slider_get_value(slider)<0)lv_slider_set_value(slider,0,LV_ANIM_OFF);
if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider)slider_changed=true;
if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
if(code==LV_EVENT_RELEASED && captured_slider==slider){
@@ -818,6 +837,46 @@ inline void event(lv_event_t *event) {
if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity);
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
}
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && lv_color_eq(current.color, color)) return;
lv_style_value_t value{}; value.color = color;
lv_obj_set_local_style_prop(obj, prop, value, selector);
}
inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && current.num == number) return;
lv_style_value_t value{}; value.num = number;
lv_obj_set_local_style_prop(obj, prop, value, selector);
}
// The white handle of a card slider sits inside the rounded end of the fill, like Home Assistant's
// slider. LVGL centres the knob on the end of the fill, so the knob is narrowed and moved back by a
// quarter of the height; the range starts below zero, so at 0 the fill is still one round end that
// holds the handle. Values below zero never leave the slider (see slider_event).
inline void slider_handle(lv_obj_t *slider, int width, int height) {
int handle = height > 20 ? 6 : 4, inset = height / 4 + handle / 2, half = height >> 1;
set_number(slider, LV_STYLE_PAD_LEFT, inset + handle / 2 - half, LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_RIGHT, handle / 2 - inset - (height - half), LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_TOP, -(height / 4), LV_PART_KNOB);
set_number(slider, LV_STYLE_PAD_BOTTOM, -(height / 4), LV_PART_KNOB);
int below = width > height ? 1000 * height / (width - height) : 0;
if (lv_slider_get_min_value(slider) != -below || lv_slider_get_max_value(slider) != 1000) lv_slider_set_range(slider, -below, 1000);
}
inline void set_hidden(lv_obj_t *obj, bool hidden) { if (hidden) lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); }
// A card's size as its styles request it. LVGL's own getters only follow after a layout pass, and
// that pass walks every object on the screen, so the cards are laid out without asking for one.
inline int tile_width(const Widgets &w) {
int32_t width = lv_obj_get_style_width(w.tile, LV_PART_MAIN);
return LV_COORD_IS_PX(width) ? width : lv_obj_get_width(w.tile);
}
inline int content_width(const Widgets &w) {
return tile_width(w) - lv_obj_get_style_space_left(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_right(w.tile, LV_PART_MAIN);
}
inline int content_height(const Widgets &w) {
return lv_obj_get_height(w.tile) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN);
}
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
lv_obj_update_layout(tile);
// Compact cards need room for two text lines and a separate dimmer track.
@@ -838,7 +897,7 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value,
// A short white bar inside the fill as handle, like the control sliders (invisible before 0.2.20).
int strip=lv_obj_get_height(tile)>80?28:10;
lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(w.slider,LV_OPA_COVER,LV_PART_KNOB);
lv_obj_set_style_pad_hor(w.slider,-(strip/2-(strip>20?3:2)),LV_PART_KNOB);lv_obj_set_style_pad_ver(w.slider,-(strip/4),LV_PART_KNOB);
slider_handle(w.slider,lv_obj_get_width(tile)-24,strip);
lv_obj_set_style_border_width(w.slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(w.slider,0,LV_PART_KNOB);
lv_obj_set_style_radius(w.slider,14,LV_PART_MAIN);lv_obj_set_style_radius(w.slider,14,LV_PART_INDICATOR);lv_obj_set_style_radius(w.slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
@@ -893,10 +952,16 @@ inline uint32_t domain_accent(const Tile &t) {
}
// Custom cards draw into a transparent `extra` container; parts are rebuilt only
// when a slot changes mode, so paging keeps RAM use flat on the CYD.
// A slot whose next card draws no custom part only hides them: paging back to the clock or
// graph in that slot reuses its objects instead of creating them again (tens of ms per card).
inline void hide_extra(Widgets &w) {
if(!w.extra)return;
if(!lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
w.fill_points=nullptr;w.fill_count=0;
}
inline void end_extra(Widgets &w) {
if(!w.extra)return;
lv_obj_add_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
w.fill_points=nullptr;w.fill_count=0;
hide_extra(w);
if(!w.extra_mode.empty()){lv_obj_clean(w.extra);w.parts.fill(nullptr);w.extra_mode.clear();delete[] w.points;w.points=nullptr;}
}
// Two triangles per segment between the polyline and its baseline. No canvas
@@ -920,7 +985,9 @@ inline void begin_extra(Widgets &w,const char *mode,int width,int height) {
}
if(w.extra_mode!=mode){end_extra(w);w.extra_mode=mode;w.points=new lv_point_precise_t[POINT_BUFFER];w.cached_active=-1;}
w.fill_points=nullptr;w.fill_count=0;
lv_obj_set_pos(w.extra,0,0);lv_obj_set_size(w.extra,width,height);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);
// Parts that were hidden kept the colours of the card they last showed.
if(lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){w.cached_active=-1;lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);}
lv_obj_set_pos(w.extra,0,0);lv_obj_set_size(w.extra,width,height);
}
// Catmull-Rom curve through the samples: the trend reads smoothly without extra data.
inline unsigned smooth(const lv_point_precise_t *in,unsigned n,lv_point_precise_t *out,unsigned capacity,int width,int height) {
@@ -1122,11 +1189,18 @@ inline lv_obj_t *panel_obj(lv_obj_t *parent,bool clickable) {
if(clickable)lv_obj_add_flag(o,LV_OBJ_FLAG_CLICKABLE);else lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);
return o;
}
// Like the custom parts: a card without controls hides the slot's panel and a later card with
// the same control set shows it again.
inline void hide_panel(Widgets &w) {
if(!w.panel)return;
if(!lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);
w.panel_w=0;
if(captured_slider && captured_slider==w.control_slider)captured_slider=nullptr;
}
inline void end_panel(Widgets &w) {
if(!w.panel)return;
lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_w=0;
hide_panel(w);
if(!w.panel_mode.empty()){
if(captured_slider && captured_slider==w.control_slider)captured_slider=nullptr;
lv_obj_clean(w.panel);w.keys.fill(nullptr);w.key_icons.fill(nullptr);w.key_checked.fill(-1);
w.pill=w.pill_value=w.knob=w.control_slider=nullptr;w.knob_on=-1;w.panel_mode.clear();
}
@@ -1181,13 +1255,13 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
int h=mode=="volume"?m.slider_h:m.slider_h;
auto *slider=lv_slider_create(w.panel);w.control_slider=slider;
lv_obj_remove_flag(slider,LV_OBJ_FLAG_GESTURE_BUBBLE);lv_obj_remove_flag(slider,LV_OBJ_FLAG_SCROLLABLE);
lv_slider_set_range(slider,0,1000);lv_obj_set_size(slider,mode=="volume"?m.slider_w:m.pill_w,h);
lv_obj_set_size(slider,mode=="volume"?m.slider_w:m.pill_w,h);
lv_obj_set_style_pad_all(slider,0,LV_PART_MAIN);
lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_MAIN);lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_INDICATOR);
// The handle is a short white bar inside the fill, like Home Assistant's slider.
lv_obj_set_style_radius(slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
lv_obj_set_style_pad_hor(slider,-(h/2-(large?3:2)),LV_PART_KNOB);lv_obj_set_style_pad_ver(slider,-(h/4),LV_PART_KNOB);
slider_handle(slider,mode=="volume"?m.slider_w:m.pill_w,h);
lv_obj_set_style_border_width(slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(slider,0,LV_PART_KNOB);
lv_obj_set_ext_click_area(slider,m.ext+2);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)w.index);
@@ -1253,11 +1327,13 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
if(w.knob_on!=(int)on){w.knob_on=on;lv_obj_set_x(w.knob,on?m.toggle_w-(m.toggle_h-8)-4:4);}
panel_w=m.toggle_w;panel_h=m.toggle_h;
}else if(mode=="run"){
panel_w=lv_obj_get_width(w.keys[0]);
// The requested width: a key created in this pass has no coordinates before LVGL's layout.
panel_w=lv_obj_get_style_width(w.keys[0],LV_PART_MAIN);
}
(void)now;
if(!panel_w){lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_w=0;return 0;}
lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN);
if(!panel_w){hide_panel(w);return 0;}
// A panel shown again kept the colours of the card it last served.
if(lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_dirty=true;}
lv_obj_set_size(w.panel,panel_w,panel_h);lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2));
w.panel_w=panel_w+m.text_gap;
return w.panel_w;
@@ -1272,17 +1348,18 @@ inline void style_panel(Widgets &w,const Tile &t,lv_color_t accent,lv_color_t te
for(unsigned n=0;n<3;++n){
auto *key=w.keys[n];if(!key)continue;
bool in_pill=w.pill && lv_obj_get_parent(key)==w.pill;
lv_obj_set_style_bg_color(key,in_pill?key_pressed:key_bg,0);
lv_obj_set_style_bg_color(key,in_pill?lv_color_mix(card,lv_color_hex(0x000000),190):key_pressed,LV_STATE_PRESSED);
lv_obj_set_style_bg_color(key,w.key_args[n]=="off" && w.panel_mode=="mode"?lv_color_hex(0x9E9E9E):accent,LV_STATE_CHECKED);
if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],w.key_checked[n]==1?lv_color_hex(0xFFFFFF):text,0);
set_color(key,LV_STYLE_BG_COLOR,in_pill?key_pressed:key_bg);
set_color(key,LV_STYLE_BG_COLOR,in_pill?lv_color_mix(card,lv_color_hex(0x000000),190):key_pressed,LV_STATE_PRESSED);
set_color(key,LV_STYLE_BG_COLOR,w.key_args[n]=="off" && w.panel_mode=="mode"?lv_color_hex(0x9E9E9E):accent,LV_STATE_CHECKED);
if(w.key_icons[n])set_color(w.key_icons[n],LV_STYLE_TEXT_COLOR,w.key_checked[n]==1?lv_color_hex(0xFFFFFF):text);
}
if(w.pill){lv_obj_set_style_bg_color(w.pill,key_bg,0);lv_obj_set_style_text_color(w.pill_value,text,0);}
if(w.pill){set_color(w.pill,LV_STYLE_BG_COLOR,key_bg);set_color(w.pill_value,LV_STYLE_TEXT_COLOR,text);}
if(w.control_slider){
lv_obj_set_style_bg_color(w.control_slider,lv_color_mix(accent,card,76),LV_PART_MAIN);
lv_obj_set_style_bg_color(w.control_slider,accent,LV_PART_INDICATOR);
lv_color_t fill=fresh() && t.active()?accent:lv_color_hex(0x9E9E9E);
set_color(w.control_slider,LV_STYLE_BG_COLOR,lv_color_mix(fill,card,76),LV_PART_MAIN);
set_color(w.control_slider,LV_STYLE_BG_COLOR,fill,LV_PART_INDICATOR);
}
if(w.knob){lv_obj_set_style_bg_color(w.keys[0],lv_color_hex(0xD7DADF),0);lv_obj_set_style_bg_color(w.keys[0],lv_color_hex(0xC5C9CF),LV_STATE_PRESSED);}
if(w.knob){set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xD7DADF));set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xC5C9CF),LV_STATE_PRESSED);}
}
inline void control_event(lv_event_t *e) {
unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16;
@@ -1328,153 +1405,200 @@ inline void set_busy(Widgets &w,bool busy,bool large){
lv_obj_set_style_bg_opa(w.spinner,LV_OPA_TRANSP,LV_PART_KNOB);lv_obj_set_style_pad_all(w.spinner,0,LV_PART_KNOB);
#endif
}
if(!lv_obj_has_flag(w.busy,LV_OBJ_FLAG_HIDDEN))return;
// Cover the whole card, padding included.
// Cover the whole card, padding included, at the width the card asks for: a slot that just turned
// wide is still single width in LVGL's own coordinates. Unchanged sizes cost LVGL nothing.
lv_obj_set_pos(w.busy,-lv_obj_get_style_pad_left(w.tile,LV_PART_MAIN),-lv_obj_get_style_pad_top(w.tile,LV_PART_MAIN));
lv_obj_set_size(w.busy,lv_obj_get_width(w.tile),lv_obj_get_height(w.tile));
lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(w.busy);
lv_obj_set_size(w.busy,tile_width(w),lv_obj_get_height(w.tile));
lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN);
// Controls and custom parts created after the sheet would otherwise paint over its right side.
if(lv_obj_get_index(w.busy)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.busy);
}
// One card: name, status, icon, layout and palette. Everything reads the model; nothing is created
// unless the card's custom parts or controls change kind.
inline void render_slot(size_t slot) {
swipe_profile::SlotTimer slot_timer(slot);
swipe_profile::Lap lap;
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)return;
const auto &t = model.tiles[w.index];
auto d=t.domain();
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Unavailable";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "Docked";
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
// Direct controls: only a wide card in the standard layout has room for the panel.
bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;}
label(w.value, value);
bool mini=t.inline_control=="slider" && !watch && t.available();
bool large_tile=lv_obj_get_height(w.tile)>80;
lap(swipe_profile::TEXT);
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath;
if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
set_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font);
// Both text boxes are one line high; the sizes come from the styles, not from a layout pass.
int title_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.title,LV_PART_MAIN));
int value_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN));
lv_obj_set_height(w.value,value_height);
int content_w=content_width(w),content_h=content_height(w);
lap(swipe_profile::LAYOUT);
set_busy(w,pending && !t.builtin(),large_tile);
lap(swipe_profile::BUSY);
for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom);
if(custom){
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);hide_panel(w);
lap(swipe_profile::GEOMETRY);
if(clock)render_clock(w,t,large_tile,content_w,content_h);
else if(sunpath)render_sunpath(w,t,large_tile,content_w,content_h);
else render_forecast(w,t,large_tile,content_w,content_h);
lap(swipe_profile::CUSTOM);
}else{
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
int slider_height=large_tile?28:8;
// A single-width graph takes the slider strip; a wide graph takes the right half.
bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
int chart_w=graph_side?content_w*55/100:0;
lap(swipe_profile::GEOMETRY);
int panel_w=with_panel && !graph?layout_panel(w,t,large_tile,content_w,content_h):0;
if(!panel_w)hide_panel(w);
lap(swipe_profile::PANEL);
int header_height=(mini||graph_strip)?content_h-slider_height-(large_tile?6:3):content_h;
int text_y=std::max(0,(header_height-text_height)/2);
int circle_size=watch?(large_tile?26:18):(mini||graph_strip)?(large_tile?36:24):(large_tile?54:36);
lv_obj_set_size(w.circle,circle_size,circle_size);
const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font;
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
lv_obj_set_width(w.title,std::max(1,text_room-text_x));
lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x)));
if(watch){
int gap=large_tile?6:2,header=std::max(circle_size,title_height);
int group_y=std::max(0,(content_h-header-gap-value_height)/2);
int value_y=group_y+header+gap;
lv_obj_set_pos(w.circle,0,group_y+(header-circle_size)/2);
lv_obj_set_pos(w.title,circle_size+(large_tile?6:4),group_y+(header-title_height)/2);
lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4));
label(w.unit,unit);
lv_point_t size;lv_text_get_size(&size,unit.c_str(),w.value_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20);
int number_width=text_room-(unit_width?unit_width+(large_tile?6:3):0);
lv_obj_set_pos(w.value,0,value_y);lv_obj_set_width(w.value,number_width);
lv_obj_set_pos(w.unit,text_room-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font));
lv_obj_set_size(w.unit,unit_width,lv_font_get_line_height(w.value_font));
set_hidden(w.unit,!unit_width);
}else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_size(w.slider,content_w,slider_height);
slider_handle(w.slider,content_w,slider_height);
if(mini){lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN);if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED) && lv_slider_get_value(w.slider)!=slider_value(t))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF);}
else lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lap(swipe_profile::GEOMETRY);
if(graph_strip)render_graph(w,t,large_tile,0,content_h-slider_height,content_w,slider_height);
else if(graph_side)render_graph(w,t,large_tile,content_w-chart_w,0,chart_w,content_h);
else hide_extra(w);
lap(swipe_profile::CUSTOM);
}
bool on = fresh() && t.active();
bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0);
if (w.cached_active == palette_state && !w.panel_dirty) { lap(swipe_profile::GEOMETRY); return; }
w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t);
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
if(d=="light" && on && t.has_hs_color)
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0);
// Darken the foreground slightly: very pale bulbs still need a visible icon.
auto icon_color=available?lv_color_mix(color,lv_color_hex(0x333333),205):lv_color_hex(0x9E9E9E);
// Off: the round end that holds the handle is grey, so the slider reads as off too.
auto fill_color=on?color:lv_color_hex(0x9E9E9E);
set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR);
set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_mix(fill_color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN);
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(t.background ? t.background : 0xFFFFFF));
set_number(w.tile,LV_STYLE_BORDER_WIDTH,1);
// "Background: none" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state.
set_number(w.tile,LV_STYLE_BG_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
set_number(w.tile,LV_STYLE_BORDER_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
set_color(w.tile,LV_STYLE_BORDER_COLOR,t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(0xDDDDDD));
set_color(w.circle,LV_STYLE_BG_COLOR,circle_color);
set_color(w.icon,LV_STYLE_TEXT_COLOR,icon_color);
auto title_color=lv_color_hex(0x1B1B1B);
auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161);
set_color(w.unit,LV_STYLE_TEXT_COLOR,lv_color_hex(0x46525E));
set_color(w.title,LV_STYLE_TEXT_COLOR,title_color);
set_color(w.value,LV_STYLE_TEXT_COLOR,value_color);
style_panel(w,t,color,title_color);
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
// The sun path sets its own colours on every render.
w.fill_color=color;
for(unsigned i=0;i<w.parts.size();++i){
auto *p=w.parts[i];if(!p)continue;
bool muted=w.extra_mode=="forecast" ? i>=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
if(lv_obj_check_type(p,&lv_label_class))set_color(p,LV_STYLE_TEXT_COLOR,muted?value_color:title_color);
else if(w.extra_mode=="sunpath")continue;
else if(lv_obj_check_type(p,&lv_line_class))set_color(p,LV_STYLE_LINE_COLOR,w.extra_mode=="graph"?color:i<12?value_color:i==13?icon_color:title_color);
else set_color(p,LV_STYLE_BG_COLOR,i==14?icon_color:value_color);
}
lap(swipe_profile::PALETTE);
}
// What the next render() draws besides the name and the top bar: the cards of the tiles a state
// message or a tick named, or every card. A refresh that names nothing (the board's own triggers,
// such as the minute tick and time sync) draws every card.
inline uint32_t dirty_tiles=0;
inline bool dirty_all=false, dirty_header=false;
inline void refresh_tile(size_t index) { if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; if(refresh)refresh(); }
inline void refresh_header_only() { dirty_header=true; if(refresh)refresh(); }
inline void refresh_all() { dirty_all=true; if(refresh)refresh(); }
// Slots whose new page content is still to come: the page fill draws them, render() leaves them.
inline std::array<bool,SLOTS_PER_PAGE> slot_pending{};
// Cards per fill step, the swipe pass included; the next waiting slot; the step timer, and whether
// LVGL refreshed the screen since the last step.
inline size_t FILL_STEP_CARDS=2;
inline size_t fill_next=SLOTS_PER_PAGE;
inline lv_timer_t *fill_timer=nullptr;
inline bool fill_refreshed=false;
inline uint32_t fill_step_ms=0;
inline bool fill_cards(size_t cards);
inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room;
room_label=room; swipe_profile::Lap lap;
label(room, !model.configured ? "Choose tiles in HA" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
render_header();
for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)continue;
const auto &t = model.tiles[w.index];
auto d=t.domain();
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Unavailable";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "Docked";
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
// Direct controls: only a wide card in the standard layout has room for the panel.
bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;}
label(w.value, value);
bool mini=t.inline_control=="slider" && !watch && t.available();
bool large_tile=lv_obj_get_height(w.tile)>80;
set_busy(w,pending && !t.builtin(),large_tile);
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath;
if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
set_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font);
lv_obj_set_height(w.value,lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN)));
lv_obj_update_layout(w.tile);
int content_width=lv_obj_get_content_width(w.tile),content_height=lv_obj_get_content_height(w.tile);
for(auto *o:{w.title,w.value,w.circle,w.unit}){if(custom)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);else lv_obj_remove_flag(o,LV_OBJ_FLAG_HIDDEN);}
if(custom){
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);end_panel(w);
if(clock)render_clock(w,t,large_tile,content_width,content_height);
else if(sunpath)render_sunpath(w,t,large_tile,content_width,content_height);
else render_forecast(w,t,large_tile,content_width,content_height);
}else{
int title_height=lv_obj_get_height(w.title),value_height=lv_obj_get_height(w.value);
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
int slider_height=large_tile?28:8;
// A single-width graph takes the slider strip; a wide graph takes the right half.
bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
int chart_w=graph_side?content_width*55/100:0;
int panel_w=with_panel && !graph?layout_panel(w,t,large_tile,content_width,content_height):0;
if(!panel_w)end_panel(w);
int header_height=(mini||graph_strip)?content_height-slider_height-(large_tile?6:3):content_height;
int text_y=std::max(0,(header_height-text_height)/2);
int circle_size=watch?(large_tile?26:18):(mini||graph_strip)?(large_tile?36:24):(large_tile?54:36);
lv_obj_set_size(w.circle,circle_size,circle_size);
const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font;
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
int text_room=content_width-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
lv_obj_set_width(w.title,std::max(1,text_room-text_x));
lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x)));
if(watch){
int gap=large_tile?6:2,header=std::max(circle_size,title_height);
int group_y=std::max(0,(content_height-header-gap-value_height)/2);
int value_y=group_y+header+gap;
lv_obj_set_pos(w.circle,0,group_y+(header-circle_size)/2);
lv_obj_set_pos(w.title,circle_size+(large_tile?6:4),group_y+(header-title_height)/2);
lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4));
label(w.unit,unit);
lv_point_t size;lv_text_get_size(&size,unit.c_str(),w.value_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20);
int number_width=text_room-(unit_width?unit_width+(large_tile?6:3):0);
lv_obj_set_pos(w.value,0,value_y);lv_obj_set_width(w.value,number_width);
lv_obj_set_pos(w.unit,text_room-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font));
lv_obj_set_size(w.unit,unit_width,lv_font_get_line_height(w.value_font));
if(unit_width)lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
}else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
lv_obj_set_size(w.slider,content_width,slider_height);
if(mini){lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN);if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF);}
else lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
if(graph_strip)render_graph(w,t,large_tile,0,content_height-slider_height,content_width,slider_height);
else if(graph_side)render_graph(w,t,large_tile,content_width-chart_w,0,chart_w,content_height);
else end_extra(w);
}
bool on = fresh() && t.active();
bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0);
if (w.cached_active == palette_state && !w.panel_dirty) continue;
w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t);
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
if(d=="light" && on && t.has_hs_color)
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0);
// Darken the foreground slightly: very pale bulbs still need a visible icon.
auto icon_color=available?lv_color_mix(color,lv_color_hex(0x333333),205):lv_color_hex(0x9E9E9E);
lv_obj_set_style_bg_color(w.slider,color,LV_PART_INDICATOR);
lv_obj_set_style_bg_color(w.slider,lv_color_mix(color,lv_color_hex(0xFFFFFF),30),LV_PART_MAIN);
lv_obj_set_style_bg_color(w.tile, lv_color_hex(t.background ? t.background : 0xFFFFFF), 0);
lv_obj_set_style_border_width(w.tile, 1, 0);
// "Background: none" hides only the card; geometry and padding stay identical,
// and the pressed flash still shows because it lives on the PRESSED state.
lv_obj_set_style_bg_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
lv_obj_set_style_border_opa(w.tile, t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER, 0);
lv_obj_set_style_border_color(w.tile, t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(0xDDDDDD), 0);
lv_obj_set_style_bg_color(w.circle,circle_color,0);
lv_obj_set_style_text_color(w.icon,icon_color,0);
auto title_color=lv_color_hex(0x1B1B1B);
auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161);
lv_obj_set_style_text_color(w.unit,lv_color_hex(0x46525E),0);
lv_obj_set_style_text_color(w.title, title_color, 0);
lv_obj_set_style_text_color(w.value, value_color, 0);
style_panel(w,t,color,title_color);
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
// The sun path sets its own colours on every render.
w.fill_color=color;
for(unsigned i=0;i<w.parts.size();++i){
auto *p=w.parts[i];if(!p)continue;
bool muted=w.extra_mode=="forecast" ? i>=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
if(lv_obj_check_type(p,&lv_label_class))lv_obj_set_style_text_color(p,muted?value_color:title_color,0);
else if(w.extra_mode=="sunpath")continue;
else if(lv_obj_check_type(p,&lv_line_class))lv_obj_set_style_line_color(p,w.extra_mode=="graph"?color:i<12?value_color:i==13?icon_color:title_color,0);
else lv_obj_set_style_bg_color(p,i==14?icon_color:value_color,0);
}
lap(swipe_profile::HEADER);
bool all=dirty_all || (!dirty_tiles && !dirty_header);
uint32_t tiles=dirty_tiles;
dirty_all=dirty_header=false;dirty_tiles=0;
for (size_t slot = 0; slot < SLOTS_PER_PAGE; ++slot) {
const auto &w=widgets[slot];
if(slot_pending[slot] || w.index>=model.count)continue;
if(all || (w.index<32 && (tiles>>w.index)&1))render_slot(slot);
}
}
@@ -1665,6 +1789,7 @@ inline void render_header() {
// Inspect actual LVGL coordinates, including padding and the loaded font metrics.
inline bool check_tile_geometry() {
bool ok=true;
if(fill_next<SLOTS_PER_PAGE){ESP_LOGW("ui_test","page fill still under way at the check");fill_cards(SLOTS_PER_PAGE);}
for(auto &w:widgets){
if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
lv_obj_update_layout(w.tile);
@@ -1695,7 +1820,7 @@ inline bool check_tile_geometry() {
else fits=fits && circle.y2<=content.y2;
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
bool watch=w.index<model.count && model.tiles[w.index].display=="watch";
bool graph=w.extra_mode=="graph";
bool graph=w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN) && w.extra_mode=="graph";
if(!watch && !graph && (mini || lv_obj_get_height(w.tile)<=80)){
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
int center_twice=content.y1+header_bottom;
@@ -1755,15 +1880,16 @@ inline unsigned page_count() {
std::array<Placement,MAX_TILES> placement;
return place(model,placement);
}
// Page switches feel immediate: the new page's card frames (right widths, no
// contents, light skeleton style) appear in the very next frame, and a one-shot
// LVGL timer fills them in right after. Keepalives and re-packing on the same
// page apply directly. Nothing is allocated, so the CYD stays comfortable.
// Page switches feel immediate without blocking touch: the swipe pass places the new page (page
// number, card widths), draws its first two cards and gives the others a light skeleton frame, all
// in the very next frame. Every following LVGL refresh draws the next two cards, so the loop, and
// the touch polling in it, runs between the steps, and a new swipe drops a fill still under way.
// Keepalives and re-packing on the same page draw at once. Nothing is allocated.
inline lv_obj_t *nav_prev=nullptr,*nav_next=nullptr,*nav_number=nullptr;
inline int applied_page=-1,target_page=0;
inline lv_timer_t *page_timer=nullptr;
inline int applied_page=-1;
// Slot assignment plus card widths and visibility for a page; contents are untouched.
inline int place_page(int page) {
swipe_profile::Lap lap;
std::array<Placement,MAX_TILES> placement;
int pages=place(model,placement);
page=std::clamp(page,0,pages-1);
@@ -1773,49 +1899,132 @@ inline int place_page(int page) {
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);end_extra(w);end_panel(w);}
}
for(auto *control:{nav_prev,nav_next,nav_number}){
if(pages>1)lv_obj_remove_flag(control,LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(control,LV_OBJ_FLAG_HIDDEN);
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);hide_extra(w);hide_panel(w);}
}
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,pages<=1);
if(page==0)lv_obj_add_state(nav_prev,LV_STATE_DISABLED);else lv_obj_remove_state(nav_prev,LV_STATE_DISABLED);
if(page==pages-1)lv_obj_add_state(nav_next,LV_STATE_DISABLED);else lv_obj_remove_state(nav_next,LV_STATE_DISABLED);
for(auto *control:{nav_prev,nav_next})if(lv_obj_get_child_count(control))
lv_obj_set_style_text_opa(lv_obj_get_child(control,0),lv_obj_has_state(control,LV_STATE_DISABLED)?LV_OPA_30:LV_OPA_COVER,0);
std::string caption=std::to_string(page+1)+" / "+std::to_string(pages);lv_label_set_text(nav_number,caption.c_str());
set_number(lv_obj_get_child(control,0),LV_STYLE_TEXT_OPA,lv_obj_has_state(control,LV_STATE_DISABLED)?LV_OPA_30:LV_OPA_COVER);
label(nav_number,std::to_string(page+1)+" / "+std::to_string(pages));
lap(swipe_profile::PLACE);
return page;
}
inline void apply_page(int page) {
applied_page=place_page(page);
if(room_label)render(room_label);else if(refresh)refresh();
// The skeleton frame is the empty card: its contents hidden under a sheet in the card's own colour
// (the page colour for a card without a background) that covers the content area. The sheet is a
// plain rectangle inside the card's padding, so it has no corners, border or layer to render, and
// while it is up LVGL draws nothing under it. Drawing the card takes the sheet away again.
inline lv_color_t page_color(const Widgets &w) {
for(auto *o=lv_obj_get_parent(w.tile);o;o=lv_obj_get_parent(o))
if(lv_obj_get_style_bg_opa(o,LV_PART_MAIN)>=LV_OPA_MAX)return lv_obj_get_style_bg_color(o,LV_PART_MAIN);
return lv_color_hex(0xE7E7E7);
}
inline void skeleton_page(int page) {
place_page(page);
uint32_t bg=0xF4F5F7, border=0xE3E5E8;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
// A card-less tile shows no skeleton frame either.
bool bare=w.index<model.count && model.tiles[w.index].transparent;
lv_obj_set_style_bg_opa(w.tile,bare?LV_OPA_TRANSP:LV_OPA_COVER,0);
lv_obj_set_style_border_opa(w.tile,bare?LV_OPA_TRANSP:LV_OPA_COVER,0);
lv_obj_set_style_bg_color(w.tile,lv_color_hex(bg),0);
lv_obj_set_style_border_color(w.tile,lv_color_hex(border),0);
inline void skeleton(Widgets &w) {
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
if(!w.veil){
w.veil=lv_obj_create(w.tile);lv_obj_remove_style_all(w.veil);
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_opa(w.veil,LV_OPA_COVER,0);
}
const Tile *t=w.index<model.count?&model.tiles[w.index]:nullptr;
set_color(w.veil,LV_STYLE_BG_COLOR,t && t->transparent?page_color(w):lv_color_hex(t && t->background?t->background:0xFFFFFF));
lv_obj_set_pos(w.veil,0,0);
lv_obj_set_size(w.veil,content_width(w),content_height(w));
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
if(lv_obj_get_index(w.veil)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.veil);
}
inline void page_timer_done(lv_timer_t *) { page_timer=nullptr; apply_page(target_page); }
inline void drop_veil(Widgets &w) {
if(w.veil && !lv_obj_has_flag(w.veil,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
}
// Draws up to `cards` waiting cards in slot order; true once the page is complete.
inline bool fill_cards(size_t cards) {
for(size_t drawn=0;fill_next<SLOTS_PER_PAGE && drawn<cards;++fill_next){
if(!slot_pending[fill_next])continue;
slot_pending[fill_next]=false;render_slot(fill_next);drop_veil(widgets[fill_next]);++drawn;
}
while(fill_next<SLOTS_PER_PAGE && !slot_pending[fill_next])++fill_next;
fill_step_ms=esphome::millis();fill_refreshed=false;
if(fill_next<SLOTS_PER_PAGE)return false;
if(fill_timer)lv_timer_pause(fill_timer);
swipe_profile::content_complete();
return true;
}
inline void cancel_fill() {
fill_next=SLOTS_PER_PAGE;slot_pending.fill(false);
if(fill_timer)lv_timer_pause(fill_timer);
}
inline void fill_timer_done(lv_timer_t *) {
// One step per refresh, so a step's frame reaches the glass before the next step is drawn. A step
// that changed no pixel starts no refresh; the fill then moves on after a short wait.
if(fill_next>=SLOTS_PER_PAGE || (!fill_refreshed && esphome::millis()-fill_step_ms<40))return;
swipe_profile::FillTimer timer;
fill_cards(FILL_STEP_CARDS);
}
inline void apply_page(int page) {
cancel_fill();
applied_page=place_page(page);
for(auto &w:widgets)drop_veil(w);
if(room_label){dirty_all=true;render(room_label);}else refresh_all();
}
inline int *shown_page=nullptr;
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
nav_prev=previous;nav_next=next;nav_number=number;
nav_prev=previous;nav_next=next;nav_number=number;shown_page=&page;
int requested=page;
page=std::clamp(page,0,int(page_count())-1);
target_page=page;
if(applied_page<0 || page==applied_page){if(page_timer){lv_timer_delete(page_timer);page_timer=nullptr;}apply_page(page);return;}
skeleton_page(page);
if(page_timer)lv_timer_reset(page_timer);
else{page_timer=lv_timer_create(page_timer_done,60,nullptr);lv_timer_set_repeat_count(page_timer,1);}
// A swipe past the first or last page: the page on screen is already right, so nothing is
// drawn again. A layout change always lands in range or on a different page.
if(requested!=page && page==applied_page)return;
if(applied_page<0 || page==applied_page || !room_label){apply_page(page);return;}
swipe_profile::begin(applied_page,page);
swipe_profile::SkeletonTimer timer;
cancel_fill();
applied_page=place_page(page);
swipe_profile::Lap lap;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];
slot_pending[slot]=w.tile && w.index<model.count;
if(slot_pending[slot])skeleton(w);
}
for(size_t slot=SLOTS_PER_PAGE;slot<widgets.size();++slot)drop_veil(widgets[slot]);
lap(swipe_profile::SKELETON);
// The skeleton frame goes out first; the fill starts on the refresh after it.
fill_next=0;fill_step_ms=esphome::millis();fill_refreshed=false;
if(!fill_timer){
fill_timer=lv_timer_create(fill_timer_done,1,nullptr);
if(auto *display=lv_display_get_default())
lv_display_add_event_cb(display,[](lv_event_t *){fill_refreshed=true;},LV_EVENT_REFR_READY,nullptr);
}
lv_timer_resume(fill_timer);
}
#ifdef SWIPE_PROFILE
inline unsigned swipe_test_left=0, swipe_test_back=0;
inline bool swipe_test_returning=false;
inline lv_timer_t *swipe_test_timer=nullptr;
inline void swipe_test_step(lv_timer_t *timer) {
if(!shown_page || !swipe_test_left || page_count()<2){lv_timer_pause(timer);swipe_test_left=0;ESP_LOGI("swipe_prof","swipe test finished");return;}
int pages=page_count(),from=*shown_page;
*shown_page=swipe_test_returning?from-1:(from+1<pages?from+1:0);
if(*shown_page<0)*shown_page=pages-1;
show_page(*shown_page,nav_prev,nav_next,nav_number);
if(swipe_test_back && !swipe_test_returning){swipe_test_returning=true;lv_timer_set_period(timer,swipe_test_back);return;}
swipe_test_returning=false;--swipe_test_left;
lv_timer_set_period(timer,lv_timer_get_user_data(timer)?(uint32_t)(uintptr_t)lv_timer_get_user_data(timer):1200);
}
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning) {
// Cards per fill step for the measurement.
if(tuning["cards"].is<unsigned>())FILL_STEP_CARDS=std::clamp<unsigned>(tuning["cards"].as<unsigned>(),1,6);
ESP_LOGI("swipe_prof","tuning: %u cards per step",(unsigned)FILL_STEP_CARDS);
swipe_test_left=std::min(count,200u);swipe_test_back=back_ms;swipe_test_returning=false;
interval_ms=std::clamp(interval_ms,200u,10000u);
if(!swipe_test_timer)swipe_test_timer=lv_timer_create(swipe_test_step,interval_ms,(void*)(uintptr_t)interval_ms);
lv_timer_set_user_data(swipe_test_timer,(void*)(uintptr_t)interval_ms);
lv_timer_set_period(swipe_test_timer,interval_ms);lv_timer_reset(swipe_test_timer);lv_timer_resume(swipe_test_timer);
ESP_LOGI("swipe_prof","swipe test: %u page switches every %u ms, back after %u ms",swipe_test_left,interval_ms,back_ms);
}
#endif
inline uint32_t last_live_second=0;
inline int last_clock_minute=-2;
inline bool was_fresh=false;
inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
@@ -1830,11 +2039,13 @@ inline void tick() {
}
}
if(!enabled)return;
// Only the cards that change are drawn again: a clock or a finished command redraws its own card.
bool redraw=false;
auto card=[&](size_t index){ if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; redraw=true; };
// HA dropping or returning and the feed timing out change every card at once.
bool now_fresh=fresh();
if(now_fresh!=was_fresh){was_fresh=now_fresh;redraw=true;}
for(auto &t:model.tiles)if(t.pending && !t.loading(esphome::millis())){t.pending=false;redraw=true;}
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
for(size_t i=0;i<model.tiles.size();++i){auto &t=model.tiles[i];if(t.pending && !t.loading(esphome::millis())){t.pending=false;card(i);}}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
for(size_t i=0;i<model.count;++i){
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
@@ -1843,16 +2054,20 @@ inline void tick() {
if(now-t.edit_since<700 || t.awaiting_action(now))continue;
auto a=tile_controls::edit_action(t,t.edit_value);
if(a.valid()){t.edit_sent=true;t.edit_since=now;action(a.service,t.entity,a.key,a.value);}else t.edit_value=NAN;
}else if(now-t.edit_since>10000){t.edit_value=NAN;redraw=true;}
}else if(now-t.edit_since>10000){t.edit_value=NAN;card(i);}
}
// Clocks and running timers advance once per second without any HA traffic.
// Running timers advance once per second without any HA traffic; clocks show hours and minutes,
// so they are drawn again only when the minute (or the time's validity) changes.
uint32_t second=esphome::millis()/1000;
if(second!=last_live_second){
last_live_second=second;
auto now=now_time?now_time():esphome::ESPTime{};
int minute=now.is_valid()?now.day_of_year*1440+now.hour*60+now.minute:-1;
bool new_minute=minute!=last_clock_minute;last_clock_minute=minute;
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];if(!w.tile || w.index>=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
const auto &t=model.tiles[w.index];
if(t.builtin() || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))redraw=true;
if((t.builtin() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
}
}
if(redraw && refresh)refresh();
+140
View File
@@ -0,0 +1,140 @@
#pragma once
// Page swipe profiling for diagnostic builds only: compile with -DSWIPE_PROFILE=1. Without the
// define every hook below is an empty inline and the release firmware is unchanged.
// One INFO line per page switch, tag `swipe_prof`, times in ms from the swipe (show_page):
// skel CPU in the skeleton pass fill CPU in all content passes (steps = passes)
// first swipe -> first frame on the glass done swipe -> frame that completes the content
// gap longest main-loop gap (touch is polled once per loop) from the swipe until done
// frames per LVGL refresh: +start:layout/render/flush ms, flush calls, pixels
// slots CPU per slot in the content passes
// parts CPU per step of those passes, summed over the slots (see Part)
#include "lvgl.h"
#ifdef SWIPE_PROFILE
#include "esp_timer.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <array>
#include <cstdio>
#include <string>
#endif
namespace swipe_profile {
// Steps of a content pass and of the skeleton pass; `Lap` adds the time since its previous lap.
enum Part { TEXT, BUSY, LAYOUT, CUSTOM, PANEL, GEOMETRY, PALETTE, PLACE, SKELETON, HEADER, PARTS };
#ifdef SWIPE_PROFILE
inline const char *const PART_NAMES[PARTS] = {"text", "busy", "layout", "custom", "panel", "geometry", "palette", "place", "skeleton", "header"};
struct Frame { int64_t start = 0, render_start = 0, flush_start = 0; uint32_t layout = 0, render = 0, flush = 0, flushes = 0, pixels = 0; };
struct Swipe {
bool active = false, complete = false, done = false;
int from = -1, to = -1;
int64_t t0 = 0, first = 0, finished = 0, last_probe = 0;
uint32_t skeleton = 0, fill = 0, steps = 0, max_gap = 0;
std::array<uint32_t, 6> slots{};
std::array<uint32_t, PARTS> parts{};
std::array<Frame, 24> frames{};
unsigned count = 0, dropped = 0;
};
inline Swipe swipe;
inline Frame frame;
inline bool hooked = false;
inline int64_t now_us() { return esp_timer_get_time(); }
inline double ms(int64_t us) { return us / 1000.0; }
inline void emit(const char *outcome) {
auto &s = swipe;
std::string frames;
char b[96];
for (unsigned i = 0; i < s.count; ++i) {
auto &f = s.frames[i];
snprintf(b, sizeof(b), "%s+%.0f:%.1f/%.1f/%.1f:%u:%u", i ? "|" : "", ms(f.start - s.t0), ms(f.layout), ms(f.render), ms(f.flush),
(unsigned) f.flushes, (unsigned) f.pixels);
frames += b;
}
std::string slots;
for (unsigned i = 0; i < s.slots.size(); ++i) { snprintf(b, sizeof(b), "%s%.1f", i ? "," : "", ms(s.slots[i])); slots += b; }
std::string parts;
for (unsigned i = 0; i < PARTS; ++i) { snprintf(b, sizeof(b), "%s%s:%.1f", i ? "," : "", PART_NAMES[i], ms(s.parts[i])); parts += b; }
ESP_LOGI("swipe_prof", "%s %d->%d skel=%.1f fill=%.1f steps=%u first=%.0f done=%.0f gap=%.0f frames=%s%s slots=%s parts=%s", outcome, s.from, s.to,
ms(s.skeleton), ms(s.fill), (unsigned) s.steps, s.first ? ms(s.first - s.t0) : -1.0, s.finished ? ms(s.finished - s.t0) : -1.0,
ms(s.max_gap), frames.c_str(), s.dropped ? "+more" : "", slots.c_str(), parts.c_str());
s.active = false;
}
inline void display_event(lv_event_t *e) {
auto code = lv_event_get_code(e);
int64_t t = now_us();
if (code == LV_EVENT_REFR_START) { frame = Frame{}; frame.start = t; return; }
if (code == LV_EVENT_RENDER_START) { frame.render_start = t; frame.layout = t - frame.start; return; }
if (code == LV_EVENT_FLUSH_START) {
frame.flush_start = t;
if (auto *area = static_cast<lv_area_t *>(lv_event_get_param(e))) frame.pixels += lv_area_get_size(area);
return;
}
if (code == LV_EVENT_FLUSH_FINISH) { frame.flush += t - frame.flush_start; frame.flushes++; return; }
if (code == LV_EVENT_RENDER_READY) { frame.render = t - frame.render_start; return; }
if (code != LV_EVENT_REFR_READY || !swipe.active || swipe.done || !frame.flushes) return;
auto &s = swipe;
if (s.count < s.frames.size()) s.frames[s.count++] = frame; else s.dropped++;
if (!s.first) s.first = t;
// The frame that follows the last content pass puts the finished page on the glass.
if (s.complete) { s.finished = t; s.done = true; }
}
// Runs at the start of every lv_timer_handler call, so the gap between two runs is one whole
// ESPHome loop: whatever LVGL, the page code and every other component spent in between.
inline void probe(lv_timer_t *) {
int64_t t = now_us();
auto &s = swipe;
if (s.active) {
int64_t from = s.last_probe > s.t0 ? s.last_probe : s.t0;
s.max_gap = std::max<uint32_t>(s.max_gap, t - from);
if (s.done) emit("swipe");
else if (t - s.t0 > 3000000) emit("timeout");
}
s.last_probe = t;
}
inline void hook() {
if (hooked) return;
auto *display = lv_display_get_default();
if (!display) return;
hooked = true;
for (auto code : {LV_EVENT_REFR_START, LV_EVENT_RENDER_START, LV_EVENT_FLUSH_START, LV_EVENT_FLUSH_FINISH, LV_EVENT_RENDER_READY, LV_EVENT_REFR_READY})
lv_display_add_event_cb(display, display_event, code, nullptr);
lv_timer_create(probe, 1, nullptr);
// Every layout pass walks all of these, and every invalidation climbs their parents.
struct Count { static uint32_t of(lv_obj_t *obj) { uint32_t n = 1; for (uint32_t i = 0; i < lv_obj_get_child_count(obj); ++i) n += of(lv_obj_get_child(obj, i)); return n; } };
ESP_LOGI("swipe_prof", "objects: screen %u, top layer %u", (unsigned) Count::of(lv_display_get_screen_active(display)),
(unsigned) Count::of(lv_display_get_layer_top(display)));
}
// A page switch starts; a switch that is still filling is reported as superseded.
inline void begin(int from, int to) {
hook();
if (swipe.active) emit("superseded");
swipe = Swipe{};
swipe.active = true; swipe.from = from; swipe.to = to; swipe.t0 = now_us(); swipe.last_probe = swipe.t0;
}
struct Timer {
uint32_t *into; int64_t start;
explicit Timer(uint32_t *target) : into(swipe.active && !swipe.done ? target : nullptr), start(now_us()) {}
~Timer() { if (into) *into += now_us() - start; }
};
struct SkeletonTimer : Timer { SkeletonTimer() : Timer(&swipe.skeleton) {} };
struct FillTimer : Timer { FillTimer() : Timer(&swipe.fill) { if (into) swipe.steps++; } };
struct SlotTimer : Timer { explicit SlotTimer(size_t slot) : Timer(slot < swipe.slots.size() ? &swipe.slots[slot] : nullptr) {} };
struct Lap {
int64_t last = now_us();
void operator()(Part part) { int64_t t = now_us(); if (swipe.active && !swipe.done) swipe.parts[part] += t - last; last = t; }
};
// The page's content is complete once the pass that just ran reaches the screen.
inline void content_complete() { if (swipe.active) swipe.complete = true; }
#else
inline void begin(int, int) {}
struct SkeletonTimer { SkeletonTimer() {} };
struct FillTimer { FillTimer() {} };
struct SlotTimer { explicit SlotTimer(size_t) {} };
struct Lap { void operator()(Part) {} };
inline void content_complete() {}
#endif
} // namespace swipe_profile
+30
View File
@@ -176,6 +176,36 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.42 / firmware 0.2.36
Firmware only: storage version, tile protocol, preferences and keys are unchanged. What moved
inside `runtime_tiles.h` (measurements in docs/SWIPE_PROFILE.md):
- `render()` is split into `render_slot()`. A page switch (`show_page` to another page) places
the page, shows every card as a skeleton (a sheet in the card colour over its content area,
`Widgets::veil`) and draws two cards per LVGL refresh (`fill_cards`, `FILL_STEP_CARDS`,
`LV_EVENT_REFR_READY`, 40 ms fallback); a new switch cancels the fill. `apply_page` (same page:
keepalives, re-packing) still draws everything at once. `check_tile_geometry` finishes a fill
still under way and logs `page fill still under way at the check`.
- `refresh_tile(index)`, `refresh_header_only()` and `refresh_all()` mark what the next
`render()` draws; a plain `refresh()` from the board YAML draws every card, as before.
- A slot hides custom parts and control panels instead of deleting them and reuses them for the
same kind (`hide_extra`, `hide_panel`); `end_extra`/`end_panel` still delete on a kind change.
- Guarded style setters `set_color`/`set_number`; card content sizes from the styles
(`tile_width`, `content_width`, `content_height`) instead of `lv_obj_update_layout()`.
- Card sliders: `slider_handle()` narrows the knob and moves it back into the fill and sets the
range to `-below..1000`, so 0 still shows one round end; `slider_event` keeps values at 0 or
above, `commit_slider` clamps as before. Off cards get a grey fill.
- The busy sheet is sized from the requested card width and kept as the tile's last child.
- `show_page` ignores a request past the first or last page when that page is already shown.
- `tick()` redraws clock cards when the minute changes.
- `light_controls::setup(parent, font, width, height, icon_font)`: the icon font is new and
optional; both board profiles pass `materialdesign_icons_mini` (the glyphs palette,
thermometer and lightbulb are already in the tile icon set). Rows, sliders, `open()` and
`self_test()` keep their behaviour; the brightness range starts below 1 for the same round end.
- `swipe_profile.h` and the `swipe_test` message exist only with `-DSWIPE_PROFILE=1`; release
builds and packages never set it.
### Compatibility 0.2.41 / firmware 0.2.35
Storage version and tile protocol stay 1. Firmware 0.2.35 only raises
+100
View File
@@ -0,0 +1,100 @@
# Page swipe profile (2026-09-15, app 0.2.42 / firmware 0.2.36)
Why a page swipe on the Guition blocked the main loop for 300+ ms, what changed, and how to
measure it again. ESPHome 2026.6.2, LVGL 9.5, bench boards on USB.
## How to measure
- Build with `-DSWIPE_PROFILE=1` in `esphome: platformio_options: build_flags` (the bench package
only; release builds never carry it). Without the define every hook in
`components/smart_display/swipe_profile.h` is an empty inline.
- One INFO line per page switch, tag `swipe_prof`, times in ms from the swipe (`show_page`):
`skel` CPU of the swipe pass, `fill` CPU of the later fill steps (`steps`), `first` swipe to
the first frame on the glass, `done` swipe to the frame that completes the page, `gap` the
longest main-loop gap until then (touch is polled once per loop), `frames` per LVGL refresh
`+start:layout/render/flush:flush calls:pixels`, `slots` CPU per card, `parts` CPU per step
of the passes. The first swipe also logs the object count (Guition: 251 on the screen).
- Render and flush come from `lv_display_add_event_cb` (`LV_EVENT_RENDER_START/READY`,
`LV_EVENT_FLUSH_START/FINISH`); the loop gap from an `lv_timer` that runs at the start of
every `lv_timer_handler` call. ESPHome's "took a long time" warning only fires on a new
maximum, so it is not the metric.
- Without a finger: the diagnostic build accepts
`{"v":1,"op":"swipe_test","n":12,"ms":1400}` through the `screen_message` action (page switch
every `ms`; `"back": 150` swipes straight back, `"cards": 3` changes the fill step). It runs
inside `lv_timer_handler` instead of the touch loop; both land in the same loop iteration.
Layout on the bench Guition (the owner's): 10 tiles on 2 pages. Page 1: climate, analog clock
without background, two scripts, wide light with a mini slider. Page 2: script, vacuum, two
scripts, wide light with brightness controls.
## Results (median / p90, ms)
| Build | n | first | done | gap | skel CPU | fill CPU | frame 1 render | frame 1 flush | worst frame | frames |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 baseline (0.2.35) | 33 | 80 / 84 | 400 / 412 | 276 / 283 | 26 / 29 | 92 / 209 | 51 / 53 | 26 / 27 | 95 / 100 | 2 / 2 |
| 2 custom parts and panels kept | 12 | 84 / 86 | 285 / 302 | 163 / 170 | 29 / 30 | 91 / 100 | 49 / 52 | 25 / 26 | 94 / 100 | 2 / 2 |
| 3 fill in steps, grey skeleton | 30 | 100 / 107 | 237 / 253 | 125 / 138 | 30 / 36 | 33 / 40 | 61 / 66 | 24 / 25 | 69 / 72 | 3 / 3 |
| 7 final (0.2.36) | 12 | 52 / 54 | 244 / 250 | 74 / 75 | 12 / 13 | 45 / 46 | 38 / 40 | 19 / 21 | 40 / 42 | 4 / 4 |
| 7 final, CYD | 12 | 83 / 84 | 250 / 256 | 109 / 110 | 9 / 9 | 20 / 20 | 67 / 68 | 29 / 30 | 74 / 75 | 3 / 3 |
Builds 1–3 are touch swipes by the owner (build 3 includes taps on the page buttons, which
run inside `lv_timer_handler` and show about 30 ms more gap); build 7 is `swipe_test`.
`worst frame` is layout plus render of the slowest refresh. The CYD had no baseline run in
this round; its log once showed `lvgl took a long time (397 ms)` before.
CPU per card in the content pass, median, baseline → final: to page 1 `11.8, 105.0, 44.8,
12.1, 31.6` → `4.9, 10.1, 3.4, 3.5, 20.3`; to page 2 `12.0, 13.8, 12.9, 12.1, 30.8` →
`4.9, 7.8, 3.4, 3.1, 21.4`.
## What the numbers showed
- The stall was mostly our own content pass, not LVGL's drawing: 207 ms CPU on page 1 before
a single pixel, then an 80 ms frame. The clock card's 18 parts were deleted when the slot
showed another card and created again on the way back (105 ms); the next card paid the
layout of those new objects (45 ms); the wide light rebuilt its control panel (30 ms).
- Every LVGL mutation costs about 0.3 ms on the Guition (objects and styles live in PSRAM,
16 KB instruction cache, 32 KB data cache), and `lv_obj_update_layout()` walks the whole
screen. A `lv_obj_set_style_*` with the value the object already has still refreshes and
invalidates it.
- Flush is about a third of a frame (19–26 ms per ~110–150k px, including LVGL's RGB565 byte
swap), rendering the rest.
## What changed
1. **Fill in steps** (task step 2). `render()` is split into `render_slot()`. The swipe pass
places the page (page number, card widths) and shows every card as a skeleton; each LVGL
refresh after that draws the next two cards (`fill_cards`, one step per `LV_EVENT_REFR_READY`,
40 ms fallback when a step changed no pixel). A new swipe cancels the fill. `render()` leaves
waiting cards to the fill; state messages, commands and ticks now draw only their own card
(`refresh_tile`), the top bar only the bar (`refresh_header_only`); a refresh from the board
YAML (minute tick, time sync) still draws every card.
2. **One cheap skeleton** (step 3). The skeleton is the empty card: contents hidden under a
sheet in the card's colour over the content area (plain rectangle, no corners, border or
layer). The card itself is not restyled and restored any more. First frame 80 → 52 ms.
3. **Less work per card.** Custom parts and control panels are hidden instead of deleted when
a slot shows another card and reused when the same kind comes back. Colours and opacities go
through guarded setters (`set_color`, `set_number`) that skip unchanged values. Card content
sizes come from the styles, so no layout pass per card. Swiping past the first or last page
draws nothing. Clock cards redraw when the minute changes instead of every second.
4. **Draw buffer** (step 4): not changed. Frames flush 1–10 areas of at most ~20k px each,
below the 25 % buffer (57.6k px), so a larger buffer would not merge them. Direct mode into
the RGB framebuffer would need glue around ESPHome's `lvgl`/`st7701s` components, and the
owner prefers staying on ESPHome's and LVGL's defaults. Dropping LVGL's byte swap
(`byte_order: little_endian` with a 16-pin `data_pins` list) was not tried for the same
reason (colour risk on every Guition). `LV_OBJ_STYLE_CACHE=1` was built but not measured
and is not enabled.
5. **Styles** (step 5): the guarded setters above. Not done: widget `opa` on the disabled
state of panel keys and detail buttons (only while a command is under way; replacing it
with `bg_opa`/`text_opa` does not look identical for checked keys), pre-mixed graph and sun
fills (no graph on the bench layout), a separate measurement of rounded corner masks.
Tried and dropped: a fade-in of each card under a fading sheet (150–180 ms). Completion went to
390–550 ms with 50–100 ms frames (about 10–12 fps) because fading cards redraw completely every
frame; the owner preferred speed.
## Remaining
- CYD: the longest gap is the skeleton frame itself over SPI (51.6k px, 67 ms render incl.
29 ms flush), about 110 ms.
- A wide card that alternates between a mini slider and a control panel in the same slot still
costs about 20 ms CPU per switch (real geometry and font changes).
+49
View File
@@ -0,0 +1,49 @@
# Test results app 0.2.42 / firmware 0.2.36 (2026-09-15)
Smooth page swipes, slider handles inside the fill, the busy sheet over the whole card and a new
colour card for lights. See CHANGELOG 0.2.42, RELEASING "Compatibility 0.2.42" and
docs/SWIPE_PROFILE.md for the measurements.
## Automated
- Python (`.venv-portal`): 139 tests OK.
- C++ (clang++ -std=c++17 -Wall -Wextra -Werror -I.): 13/13 PASS.
- `generate_packages.py --check` green; `node --check app.js` clean.
- ESPHome 2026.6.2 compile SUCCESS, one after another per board (manual profile first, then Easy
Setup, so the upload took the Easy build): `guition-device.yaml` (RAM 32.6%, flash 19.7%),
`easy-guition-device.yaml` (RAM 32.5%, flash 18.7%), `device.yaml` (RAM 31.6%, flash 82.3%),
`easy-cyd-device.yaml` (RAM 31.5%, flash 77.5%). Packages carry `SCREEN_FIRMWARE_VERSION` 0.2.36.
## Bench boards (USB)
- Both Easy Setup builds uploaded over USB; `device_info` reports project 0.2.36, compiled
2026-09-15 10:54:46 (Guition) and 10:54:48 (CYD), matching the build logs.
- `diagnostics/run_ui_test.py` on both release builds: 10/10 `page_check=PASS`, 50 overlay render
cycles, no failures, no `page fill still under way` line. The same passed on the diagnostic
builds before.
- Swipe measurements with `-DSWIPE_PROFILE=1` builds (same code plus the profiler), 12 page
switches each through `swipe_test`, median / p90:
- Guition (owner's layout, 10 tiles, 2 pages): first frame 52 / 54 ms, page complete
244 / 250 ms, longest loop gap 74 / 75 ms (baseline 276 / 283 ms), slowest frame 40 / 42 ms.
- CYD (8 tiles, 2 pages): first frame 83 / 84 ms, page complete 250 / 256 ms, longest loop gap
109 / 110 ms (the skeleton frame over SPI).
- Earlier builds of this round were swiped by hand by the owner on the Guition (33 swipes for the
baseline, 30 on the step fill); a fade variant was tried and dropped at the owner's request.
## Renders (ESPHome host + SDL, both boards, firmware code of this release)
- Light cards at 1, 3, 5, 60, 90 and 100 %: the white handle sits inside the fill on mini sliders
and control sliders, 1 % shows one round end with the handle inside.
- Busy sheet on a wide card with brightness controls: covers the whole card, spinner centred.
- Colour card at both ends and in the middle of every track (knobs whole, not cut off), and for a
light with only colour temperature or only colour (two cards each). The close and done
buttons are unchanged; the demo preview hides them.
## Not tested / for the owner
- The final release build by hand on the glass (the owner swiped the earlier builds of this round).
- The grey slider end of a light that is off on a real light (render path only through the palette
code; the demo lights were on).
- The colour card on a real colour light with Home Assistant commands (renders use the demo
preview without actions).
- The update from 0.2.35 to 0.2.36 through the app.
+2 -2
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.35"
SCREEN_FIRMWARE_VERSION: "0.2.36"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -726,7 +726,7 @@ esphome:
- lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8);
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"));
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font());
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+2 -2
View File
@@ -57,7 +57,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.35"
SCREEN_FIRMWARE_VERSION: "0.2.36"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -918,7 +918,7 @@ esphome:
- lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8);
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"));
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font());
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+2 -2
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.35"
SCREEN_FIRMWARE_VERSION: "0.2.36"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -922,7 +922,7 @@ esphome:
- lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8);
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"));
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font());
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+2 -2
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.35"
SCREEN_FIRMWARE_VERSION: "0.2.36"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -730,7 +730,7 @@ esphome:
- lambda: |-
static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"};
if (!runtime_tiles::enabled) light_controls::subscribe(entities, 8);
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"));
light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}"), id(materialdesign_icons_mini)->get_lv_font());
light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); };
light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); };
light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); };
+11
View File
@@ -1,3 +1,14 @@
## 0.2.42 (firmware 0.2.36)
Smoother page swipes and a new colour card for lights.
- **Page swipes that keep up with your finger.** The next page shows up at once as empty cards and fills in two cards at a time, top to bottom, while the screen keeps reading touch in between, so a quick second swipe is no longer lost. On the Guition the longest pause during a swipe went from about 280 ms to about 75 ms, and the page is complete in about a quarter of a second. Swiping past the first or last page no longer redraws the page.
- **Less redrawing overall.** A state change redraws only its own card, and clock cards redraw once a minute instead of every second.
- **Slider handles sit inside the fill**, like in Home Assistant, also at 1 %. A light that is off shows a grey slider end.
- **The busy sheet covers the whole card**, including the controls of a wide card.
- **New colour card for lights.** Colour, colour temperature and brightness each get their own card with a rounded track: a knob in the chosen colour for colour and temperature, and a filled slider with the handle inside for brightness. Lights that only do colour temperature, or only colour, show just their cards. The knob no longer gets cut off at the ends of the track.
- Firmware 0.2.36 for both boards; the app offers the update.
## 0.2.41 (firmware 0.2.35)
See ESP Screens before you install it. What the screens show and what you can set stay the same.
+1 -1
View File
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.35'
FIRMWARE_VERSION = '0.2.36'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.41"
version: "0.2.42"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen