From e7e173d3b7e37912dc5d9816fdbe7de5b3556f81 Mon Sep 17 00:00:00 2001
From: Max Gramser
Date: Fri, 18 Sep 2026 21:38:20 +0200
Subject: [PATCH] Give a lamp's modes a page: the effects, palettes, presets,
speed and intensity of a WLED behind its colour card, with a drum picker and
the effect's name on the tile (0.2.83)
A light that offers effects gets a sparkles key at the top right of its colour
card. It opens a page with a row per thing the lamp offers, all from Home
Assistant: the light's effect and the select entities of its device (a WLED's
colour palette, preset and playlist) with what they are set to, and a slider per
number entity of the device (speed, intensity). Names are Home Assistant's own
English entity translations, icons its frontend's; nothing about a brand is
hardcoded beyond a preferred order of translation keys.
A row opens LVGL's roller with every name Home Assistant lists at that moment,
asked for when the picker opens (esphome.screen_options, answered with
op: options, one page of names per message), the effects alphabetically with
Solid on top. The check key at the top right sends the choice as one action;
the back key sends nothing. A slider sends number.set_value when let go. While
an effect runs, the tile names it instead of the brightness.
Firmware 0.2.70: effects_page.h (built when it opens, gone when it closes),
Extra.effect/option_rows/number_rows, options_request, a hidden roller widget
so ESPHome compiles LVGL's roller, five firmware-only glyphs. App 0.2.83:
light_effects.py, the effect attribute, the device rows in the state message,
card_options_loop. Checked on the host with a virtual finger (35/35 on both
boards) and compiled for both ESP32 boards; CYD 1,675,424 B (91.3 %).
Co-Authored-By: Claude Fable 5.1
---
README.md | 7 +-
README_EXTENDED.md | 8 +
components/smart_display/effects_page.h | 473 ++++++++++++++++++
components/smart_display/runtime_model.h | 10 +-
components/smart_display/runtime_tiles.h | 56 +++
components/smart_display/tile_controls.h | 8 +
components/smart_display/tile_icon_names.h | 5 +
docs/TEST_RESULTS_0283.md | 64 +++
docs/images/cyd-effects.png | Bin 0 -> 13740 bytes
docs/images/guition-effects-picker.png | Bin 0 -> 16701 bytes
docs/images/guition-effects.png | Bin 0 -> 21738 bytes
guition-4848s040.yaml | 63 ++-
home-like-2432s028.yaml | 63 ++-
packages/cyd.yaml | 63 ++-
packages/guition.yaml | 63 ++-
screen_manager/CHANGELOG.md | 9 +
screen_manager/app/core.py | 14 +-
screen_manager/app/light_effects.py | 210 ++++++++
screen_manager/app/server.py | 62 ++-
...-5Ia.woff => bar-roboto-400-DWGTva7D.woff} | Bin 14120 -> 14120 bytes
...L9yA.woff => bar-roboto-500-B8TsR_Fs.woff} | Bin 14240 -> 14240 bytes
...{index-CKR1N3aP.css => index-C6Sfd-UM.css} | 2 +-
.../{index-Cp13kZoB.js => index-CteHDGjl.js} | 0
.../static/assets/tile-icons-BawP5y9D.woff | Bin 24044 -> 0 bytes
.../static/assets/tile-icons-Dqh6fj0Q.woff | Bin 0 -> 24396 bytes
screen_manager/app/static/index.html | 4 +-
screen_manager/app/tile_icons.py | 7 +
screen_manager/config.yaml | 2 +-
tests/test_effects_page.cpp | 73 +++
tests/test_light_effects.py | 160 ++++++
web/src/assets/bar-roboto-400.woff | Bin 14120 -> 14120 bytes
web/src/assets/bar-roboto-500.woff | Bin 14240 -> 14240 bytes
web/src/assets/tile-icons.woff | Bin 24044 -> 24396 bytes
33 files changed, 1409 insertions(+), 17 deletions(-)
create mode 100644 components/smart_display/effects_page.h
create mode 100644 docs/TEST_RESULTS_0283.md
create mode 100644 docs/images/cyd-effects.png
create mode 100644 docs/images/guition-effects-picker.png
create mode 100644 docs/images/guition-effects.png
create mode 100644 screen_manager/app/light_effects.py
rename screen_manager/app/static/assets/{bar-roboto-400-H0q_-5Ia.woff => bar-roboto-400-DWGTva7D.woff} (99%)
rename screen_manager/app/static/assets/{bar-roboto-500-Bn7ZL9yA.woff => bar-roboto-500-B8TsR_Fs.woff} (99%)
rename screen_manager/app/static/assets/{index-CKR1N3aP.css => index-C6Sfd-UM.css} (99%)
rename screen_manager/app/static/assets/{index-Cp13kZoB.js => index-CteHDGjl.js} (100%)
delete mode 100644 screen_manager/app/static/assets/tile-icons-BawP5y9D.woff
create mode 100644 screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff
create mode 100644 tests/test_effects_page.cpp
create mode 100644 tests/test_light_effects.py
diff --git a/README.md b/README.md
index ad15623..4e93065 100644
--- a/README.md
+++ b/README.md
@@ -51,12 +51,17 @@ also be changed on the screen or by an automation, and your own YAML for one scr
+
+
+
+
+
-Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.
+Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.
diff --git a/README_EXTENDED.md b/README_EXTENDED.md
index 03b607a..9eabe99 100644
--- a/README_EXTENDED.md
+++ b/README_EXTENDED.md
@@ -32,6 +32,14 @@ on the screen itself, and how updates work.
dial; double-width shows the digital time with the date beside it.
- **Light control:** brightness, rainbow color, and white temperature according to
the light's capabilities. Open the detailed control with a long touch.
+- **Effects (firmware 0.2.70+):** a light that offers effects (a WLED, a Hue with
+ its candle effect) gets a sparkles key at the top right of its colour card. It
+ opens a page with a row per thing the lamp offers, named and ordered as Home
+ Assistant lists them: the light's effect and the select entities of its device
+ (a WLED's colour palette, preset and playlist), plus a slider per number entity
+ (speed, intensity). A row opens a drum picker with every name Home Assistant has
+ at that moment; the check at the top right sends the choice. While an effect
+ runs, the tile names it instead of the brightness.
- **More cards:** climate, vacuum, fan, cover, media player, sensors,
select/input_select, number/input_number, switches, scenes, scripts, and
buttons.
diff --git a/components/smart_display/effects_page.h b/components/smart_display/effects_page.h
new file mode 100644
index 0000000..66d7dcc
--- /dev/null
+++ b/components/smart_display/effects_page.h
@@ -0,0 +1,473 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "runtime_model.h"
+
+// A light's effects page (firmware 0.2.70+, app 0.2.83+): the modes of a lamp such as a WLED, reached from the
+// sparkles key at the top right of the light's colour card. One card of rows, each the name of something the lamp
+// offers and what it is set to (the light's effect, and the select entities of its device: colour palette, preset,
+// playlist), and under it a slider card per number entity of the device (speed, intensity). A row opens a picker:
+// LVGL's roller with every name Home Assistant lists at that moment (asked for when the picker opens, one page of
+// names per message), and the check key at the top right sends the name the drum stands on. Nothing here knows a brand: the rows,
+// their names and their icons come from Home Assistant through the add-on.
+//
+// Like the settings page, the page is built when it opens and thrown away when it closes, so it costs nothing while
+// closed. The model part (percentages, texts, what the page can show) stays free of LVGL for tests/test_effects_page.cpp.
+namespace effects_page {
+using runtime_tiles::NumberRow;
+using runtime_tiles::OptionRow;
+using runtime_tiles::Tile;
+
+// LightEntityFeature.EFFECT: the light offers an effect list.
+constexpr uint32_t EFFECT_FEATURE = 4;
+// Names a picker holds at most; a WLED lists about 220 effects.
+constexpr size_t MAX_NAMES = 400;
+// A chosen name stays on its row while Home Assistant still reports the old one, this long at most.
+constexpr uint32_t SENT_HOLD_MS = 4000;
+
+inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
+// Whether a light has an effects page: it offers effects, or its device has rows to show.
+inline bool available(const Tile &t) {
+ return t.domain() == "light" && ((t.supported & EFFECT_FEATURE) != 0 || !t.extra().option_rows.empty() || !t.extra().number_rows.empty());
+}
+// A number's place on its slider, 0-100 % of its range.
+inline int percent_of(float value, float low, float high) {
+ if (!std::isfinite(value) || !(high > low)) return 0;
+ return clamp(static_cast(std::lround((value - low) * 100 / (high - low))), 0, 100);
+}
+// The number a slider stands for, on the entity's own step and inside its range.
+inline float value_at(int percent, float low, float high, float step) {
+ float value = low + (high - low) * clamp(percent, 0, 100) / 100.0f;
+ if (step > 0) value = low + std::round((value - low) / step) * step;
+ return std::min(high, std::max(low, value));
+}
+// A number as Home Assistant's action takes it: "128", "12.5", never "128.000000".
+inline std::string number_text(float value) {
+ char b[24];
+ snprintf(b, sizeof(b), "%.3f", value);
+ std::string text = b;
+ while (text.size() > 1 && text.back() == '0') text.pop_back();
+ if (text.back() == '.') text.pop_back();
+ return text;
+}
+inline std::string percent_text(int percent) { return std::to_string(percent) + " %"; }
+// The roller takes its names as one string, a line per name.
+inline std::string joined(const std::vector &names) {
+ std::string out;
+ for (size_t i = 0; i < names.size(); ++i) { if (i) out += '\n'; out += names[i]; }
+ return out;
+}
+inline int index_of(const std::vector &names, const std::string &name) {
+ for (size_t i = 0; i < names.size(); ++i) if (names[i] == name) return static_cast(i);
+ return -1;
+}
+// The value a row shows: the name Home Assistant reports, a dash while it reports none.
+inline std::string row_text(const std::string ¤t) {
+ return current.empty() || current == "unknown" || current == "unavailable" || current == "None" ? "—" : current;
+}
+
+// Sizes of both boards, from the display's width like light_controls.
+struct Metrics {
+ int width = 480, height = 480, pad = 20, bar_y = 16, bar = 60, title_y = 35, rows_y = 100, row_h = 56, inset = 18,
+ icon = 26, gap = 12, number_h = 92, track_h = 28, number_inset = 16, roller_rows = 5, roller_row_h = 56, roller_pad = 10,
+ radius = 18, knob = 4;
+};
+inline Metrics metrics(int width, int height) {
+ Metrics m;
+ m.width = width; m.height = height;
+ if (width < 480) {
+ m.pad = 12; m.bar_y = 8; m.bar = 40; m.title_y = 21; m.rows_y = 52; m.row_h = 34; m.inset = 10; m.icon = 18; m.gap = 6;
+ m.number_h = 44; m.track_h = 14; m.number_inset = 9; m.roller_row_h = 30; m.roller_pad = 5; m.radius = 10; m.knob = 3;
+ }
+ return m;
+}
+} // namespace effects_page
+
+#ifndef EFFECTS_PAGE_TEST
+#include "esphome/core/log.h"
+#include "lvgl.h"
+#include "theme.h"
+#include "tile_icon.h"
+namespace effects_page {
+// ---- what the board profile wires up at boot ----
+inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *roller_font = nullptr, *icon_font = nullptr;
+inline std::function tile_of; // a tile by entity
+inline std::function send; // service, entity, key, value
+inline std::function ask; // options_request
+inline std::function drift; // finger travel this touch
+inline int tap_limit = 0;
+inline uint32_t (*now)() = nullptr;
+
+// ---- state while open ----
+inline lv_obj_t *root = nullptr, *picker = nullptr;
+inline std::string entity; // the light
+struct RowDrawn { lv_obj_t *card = nullptr, *value = nullptr; std::string entity, sent; uint32_t sent_at = 0; bool light = false; std::string name; };
+struct NumberDrawn { lv_obj_t *slider = nullptr, *value = nullptr; std::string entity; float low = 0, high = 100, step = 1; bool dirty = false; };
+inline std::vector rows;
+inline std::vector numbers;
+// The picker's names: one entity at a time, page by page from the add-on.
+struct Options { std::string entity; std::vector names; unsigned next = 0, pages = 0; bool complete = false; uint32_t asked_at = 0; };
+inline Options options;
+inline lv_obj_t *roller = nullptr, *roller_note = nullptr;
+inline int picker_row = -1;
+
+inline uint32_t clock() { return now ? now() : lv_tick_get(); }
+// The page's sizes from the screen (a page just made has no width of its own until LVGL lays it out).
+inline Metrics screen_metrics() {
+ auto *display = lv_display_get_default();
+ return metrics(lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display));
+}
+inline bool visible() { return root != nullptr; }
+inline bool steady() { return !drift || tap_limit <= 0 || drift() <= tap_limit; }
+inline const Tile *tile() { return tile_of ? tile_of(entity) : nullptr; }
+// Whether the colour card of this light shows the sparkles key.
+inline bool offered(const std::string &light) { const Tile *t = tile_of ? tile_of(light) : nullptr; return t && available(*t); }
+
+inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
+ auto *obj = lv_obj_create(parent);
+ lv_obj_remove_style_all(obj);
+ lv_obj_remove_flag(obj, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
+ lv_obj_set_pos(obj, x, y);
+ lv_obj_set_size(obj, w, h);
+ return obj;
+}
+inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, theme::Role color,
+ lv_text_align_t align = LV_TEXT_ALIGN_LEFT) {
+ auto *label = lv_label_create(parent);
+ lv_label_set_text(label, value.c_str());
+ lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
+ lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_text_font(label, font, 0);
+ lv_obj_set_style_text_color(label, theme::color(color), 0);
+ lv_obj_set_style_text_align(label, align, 0);
+ lv_obj_set_height(label, lv_font_get_line_height(font));
+ return label;
+}
+inline lv_obj_t *card(lv_obj_t *parent, int x, int y, int w, int h, int radius) {
+ auto *obj = plain(parent, x, y, w, h);
+ lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
+ lv_obj_set_style_bg_color(obj, theme::color(theme::CARD), 0);
+ lv_obj_set_style_border_width(obj, 1, 0);
+ lv_obj_set_style_border_color(obj, theme::color(theme::LINE), 0);
+ lv_obj_set_style_radius(obj, radius, 0);
+ return obj;
+}
+// The glyph a row shows: Home Assistant's icon when these fonts carry it, else one of the page's own.
+inline std::string glyph(uint32_t icon, const char *fallback) {
+ if (icon && icon_font) { lv_font_glyph_dsc_t dsc; if (lv_font_get_glyph_dsc(icon_font, &dsc, icon, 0)) return tile_icon::utf8(icon); }
+ return fallback;
+}
+// A round key like the back key of every card, with a glyph in the middle.
+inline lv_obj_t *round_key(lv_obj_t *parent, int x, int y, int size, const char *glyph, lv_event_cb_t handler) {
+ auto *key = plain(parent, x, y, size, size);
+ lv_obj_add_flag(key, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_bg_opa(key, LV_OPA_COVER, 0);
+ lv_obj_set_style_bg_color(key, theme::color(theme::KEY), 0);
+ lv_obj_set_style_bg_color(key, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
+ lv_obj_set_style_radius(key, LV_RADIUS_CIRCLE, 0);
+ auto *label = text(key, glyph, icon_font ? icon_font : row_font, theme::INK);
+ lv_obj_set_width(label, LV_SIZE_CONTENT);
+ lv_obj_center(label);
+ lv_obj_add_event_cb(key, handler, LV_EVENT_SHORT_CLICKED, nullptr);
+ return key;
+}
+// The same top bar as every card: a round back key and the name in the middle, and on the picker the check key at
+// the top right, where the colour card has its sparkles key.
+inline lv_obj_t *top_bar(lv_obj_t *parent, const Metrics &m, const std::string &title, lv_event_cb_t back,
+ const char *right_glyph = nullptr, lv_event_cb_t right = nullptr) {
+ auto *key = round_key(parent, m.pad, m.bar_y, m.bar, "\U000F004D", back);
+ if (right_glyph && right) round_key(parent, m.width - m.pad - m.bar, m.bar_y, m.bar, right_glyph, right);
+ const lv_font_t *heading = title_font ? title_font : row_font;
+ auto *label = text(parent, title, heading, theme::INK, LV_TEXT_ALIGN_CENTER);
+ lv_obj_set_width(label, m.width - 2 * (m.pad + m.bar + 8));
+ lv_obj_set_pos(label, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2);
+ return key;
+}
+inline lv_obj_t *page_root() {
+ auto *obj = lv_obj_create(lv_screen_active());
+ lv_obj_remove_style_all(obj);
+ lv_obj_set_size(obj, lv_pct(100), lv_pct(100));
+ lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to what lies under it
+ lv_obj_set_style_bg_color(obj, theme::color(theme::PAGE_SOFT), 0);
+ lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
+ lv_obj_move_foreground(obj);
+ return obj;
+}
+
+// ---- the picker ----
+inline void close_picker() {
+ if (picker) { lv_obj_delete(picker); picker = nullptr; }
+ roller = roller_note = nullptr;
+ picker_row = -1;
+}
+inline void choose(const std::string &name) {
+ if (picker_row < 0 || picker_row >= static_cast(rows.size()) || name.empty()) return;
+ auto &row = rows[picker_row];
+ // What Home Assistant reports for the row now: the drum resting on it again sends nothing.
+ std::string current;
+ if (const Tile *t = tile()) {
+ if (row.light) current = t->extra().effect;
+ else for (auto &r : t->extra().option_rows) if (r.entity == row.entity) current = r.current;
+ }
+ if (name == row.sent || (row.sent.empty() && name == current)) return;
+ row.sent = name; row.sent_at = clock();
+ if (row.value) lv_label_set_text(row.value, name.c_str());
+ ESP_LOGI("effects", "%s: %s", row.entity.c_str(), name.c_str());
+ if (!send) return;
+ if (row.light) send("light.turn_on", row.entity, "effect", name);
+ else send("select.select_option", row.entity, "option", name);
+}
+// The check key: the name the drum stands on goes out, and the picker closes. Nothing is sent before it.
+inline void confirm() {
+ if (!roller) return;
+ char name[64];
+ lv_roller_get_selected_str(roller, name, sizeof(name));
+ choose(name);
+ close_picker();
+}
+inline void confirm_event(lv_event_t *) { if (steady()) confirm(); }
+inline void show_roller() {
+ if (!picker || !roller_note || options.names.empty()) return;
+ auto *holder = lv_obj_get_parent(roller_note);
+ const Metrics m = screen_metrics();
+ lv_obj_add_flag(roller_note, LV_OBJ_FLAG_HIDDEN);
+ const lv_font_t *font = roller_font ? roller_font : row_font;
+ roller = lv_roller_create(holder);
+ lv_obj_remove_style_all(roller);
+ lv_obj_set_style_bg_opa(roller, LV_OPA_TRANSP, LV_PART_MAIN);
+ lv_obj_set_style_text_font(roller, font, LV_PART_MAIN);
+ lv_obj_set_style_text_color(roller, theme::color(theme::MUTED), LV_PART_MAIN);
+ lv_obj_set_style_text_align(roller, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
+ lv_obj_set_style_text_line_space(roller, std::max(0, m.roller_row_h - (int) lv_font_get_line_height(font)), LV_PART_MAIN);
+ lv_obj_set_style_anim_duration(roller, 200, LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(roller, LV_OPA_COVER, LV_PART_SELECTED);
+ lv_obj_set_style_bg_color(roller, theme::color(theme::ACCENT_TINT), LV_PART_SELECTED);
+ lv_obj_set_style_radius(roller, m.roller_row_h / 2, LV_PART_SELECTED);
+ lv_obj_set_style_text_color(roller, theme::color(theme::INK), LV_PART_SELECTED);
+ lv_obj_set_style_text_font(roller, font, LV_PART_SELECTED);
+ lv_roller_set_options(roller, joined(options.names).c_str(), LV_ROLLER_MODE_NORMAL);
+ lv_roller_set_visible_row_count(roller, m.roller_rows);
+ // The card's width from the sizes, not from the card: a card made a moment ago has no width until LVGL lays it out.
+ lv_obj_set_width(roller, m.width - 2 * m.pad - 2 * m.inset);
+ lv_obj_center(roller);
+ const auto &row = rows[picker_row];
+ std::string current = !row.sent.empty() ? row.sent : row.light ? (tile() ? tile()->extra().effect : std::string()) : [&]() {
+ if (auto *t = tile()) for (auto &r : t->extra().option_rows) if (r.entity == row.entity) return r.current;
+ return std::string();
+ }();
+ int index = index_of(options.names, current);
+ if (index >= 0) lv_roller_set_selected(roller, static_cast(index), LV_ANIM_OFF);
+}
+// One page of names from the add-on (runtime_tiles::options_received); the roller appears with the last one.
+inline void received(const std::string &for_entity, unsigned page, unsigned pages, std::vector &&names) {
+ if (!picker || for_entity != options.entity || options.complete) return;
+ if (page != options.next) return; // an answer to an older question, or one that arrived twice
+ for (auto &name : names) { if (options.names.size() >= MAX_NAMES) break; options.names.push_back(std::move(name)); }
+ options.next = page + 1;
+ options.pages = std::max(1u, pages);
+ if (options.next < options.pages && options.names.size() < MAX_NAMES) { options.asked_at = clock(); if (ask) ask(options.entity, options.next); return; }
+ options.complete = true;
+ if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, "Nothing to choose"); return; }
+ show_roller();
+}
+inline void picker_back(lv_event_t *) { if (steady()) close_picker(); }
+inline void open_picker(int index) {
+ if (!root || index < 0 || index >= static_cast(rows.size())) return;
+ close_picker();
+ picker_row = index;
+ const auto &row = rows[index];
+ const Metrics m = screen_metrics();
+ picker = page_root();
+ top_bar(picker, m, row.name, picker_back, "\U000F012C", confirm_event);
+ const int w = m.width - 2 * m.pad, h = m.roller_rows * m.roller_row_h + 2 * m.roller_pad;
+ auto *holder = card(picker, m.pad, m.rows_y, w, h, m.radius);
+ roller_note = text(holder, "Loading...", row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER);
+ lv_obj_set_width(roller_note, w - 2 * m.inset);
+ lv_obj_center(roller_note);
+ // The names of the same entity from a moment ago serve again; anything else is asked for afresh.
+ if (options.entity == row.entity && options.complete && !options.names.empty()) { show_roller(); return; }
+ options = Options{};
+ options.entity = row.entity;
+ options.asked_at = clock();
+ if (ask) ask(row.entity, 0);
+ else lv_label_set_text(roller_note, "Not connected");
+}
+
+// ---- the page ----
+inline void row_event(lv_event_t *e) {
+ if (lv_event_get_code(e) != LV_EVENT_SHORT_CLICKED || !steady()) return;
+ open_picker(static_cast(reinterpret_cast(lv_event_get_user_data(e))));
+}
+inline void preview(NumberDrawn &n) {
+ if (n.value) lv_label_set_text(n.value, percent_text(lv_slider_get_value(n.slider)).c_str());
+}
+inline void slider_event(lv_event_t *e) {
+ auto &n = numbers[reinterpret_cast(lv_event_get_user_data(e))];
+ const auto code = lv_event_get_code(e);
+ if (code == LV_EVENT_VALUE_CHANGED) { n.dirty = true; preview(n); }
+ if (code == LV_EVENT_PRESS_LOST) n.dirty = false;
+ if (code == LV_EVENT_RELEASED && n.dirty) {
+ n.dirty = false;
+ const float value = value_at(lv_slider_get_value(n.slider), n.low, n.high, n.step);
+ ESP_LOGI("effects", "%s: %s", n.entity.c_str(), number_text(value).c_str());
+ if (send) send("number.set_value", n.entity, "value", number_text(value));
+ }
+}
+inline void back_event(lv_event_t *) { if (steady()) { close_picker(); if (root) { lv_obj_delete(root); root = nullptr; } rows.clear(); numbers.clear(); } }
+inline void draw() {
+ if (!root) return;
+ lv_obj_clean(root);
+ rows.clear(); numbers.clear();
+ const Tile *t = tile();
+ const Metrics m = screen_metrics();
+ top_bar(root, m, t ? (t->name.empty() ? t->entity : t->name) : entity, back_event);
+ const int w = m.width - 2 * m.pad;
+ // The rows: the light's effect first, then the selects of its device.
+ struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; };
+ std::vector specs;
+ if (t && (t->supported & EFFECT_FEATURE)) specs.push_back({t->entity, "Effect", t->extra().effect, "\U000F0674", 0, true});
+ if (t) for (auto &r : t->extra().option_rows) specs.push_back({r.entity, r.name, r.current, "\U000F0411", r.icon, false});
+ int y = m.rows_y;
+ if (!specs.empty()) {
+ auto *holder = card(root, m.pad, y, w, static_cast(specs.size()) * m.row_h, m.radius);
+ for (size_t i = 0; i < specs.size(); ++i) {
+ auto &spec = specs[i];
+ auto *row = plain(holder, 0, static_cast(i) * m.row_h, w, m.row_h);
+ lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED);
+ lv_obj_set_style_bg_color(row, theme::color(theme::CARD_PRESSED), LV_STATE_PRESSED);
+ lv_obj_set_style_radius(row, m.radius, 0);
+ lv_obj_add_event_cb(row, row_event, LV_EVENT_SHORT_CLICKED, reinterpret_cast(static_cast(i)));
+ const int icon_h = icon_font ? lv_font_get_line_height(icon_font) : 0, text_h = lv_font_get_line_height(row_font);
+ int x = m.inset;
+ if (icon_font) {
+ auto *icon = text(row, glyph(spec.icon, spec.fallback.c_str()), icon_font, theme::MUTED);
+ lv_obj_set_width(icon, LV_SIZE_CONTENT);
+ lv_obj_set_pos(icon, m.inset - 2, (m.row_h - icon_h) / 2);
+ x += m.icon + 6;
+ }
+ auto *name = text(row, spec.name, row_font, theme::INK);
+ lv_obj_set_pos(name, x, (m.row_h - text_h) / 2);
+ const int chevron_w = icon_font ? m.icon : 0;
+ lv_obj_set_width(name, std::max(20, w / 2 - x));
+ if (icon_font) {
+ auto *chevron = text(row, "\U000F0142", icon_font, theme::CHEVRON);
+ lv_obj_set_width(chevron, LV_SIZE_CONTENT);
+ lv_obj_set_pos(chevron, w - m.inset - chevron_w + 2, (m.row_h - icon_h) / 2);
+ }
+ auto *value = text(row, row_text(spec.current), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT);
+ const int value_x = x + w / 2 - x + 4;
+ lv_obj_set_pos(value, value_x, (m.row_h - text_h) / 2);
+ lv_obj_set_width(value, std::max(20, w - m.inset - chevron_w - 4 - value_x));
+ RowDrawn drawn; drawn.card = row; drawn.value = value; drawn.entity = spec.entity; drawn.light = spec.light; drawn.name = spec.name;
+ rows.push_back(std::move(drawn));
+ }
+ y += static_cast(specs.size()) * m.row_h + m.gap;
+ }
+ // The numbers: half-width slider cards side by side, like the brightness slider of the colour card.
+ if (t && !t->extra().number_rows.empty()) {
+ const size_t count = std::min(2, t->extra().number_rows.size());
+ const int card_w = count == 1 ? w : (w - m.gap) / 2;
+ for (size_t i = 0; i < count; ++i) {
+ const auto &n = t->extra().number_rows[i];
+ const int x = m.pad + static_cast(i) * (card_w + m.gap);
+ auto *holder = card(root, x, y, card_w, m.number_h, m.radius);
+ const int text_h = lv_font_get_line_height(row_font), text_y = m.number_inset * 3 / 4;
+ int tx = m.number_inset;
+ if (icon_font) {
+ auto *icon = text(holder, glyph(n.icon, "\U000F00DF"), icon_font, theme::MUTED);
+ lv_obj_set_width(icon, LV_SIZE_CONTENT);
+ const int icon_h = lv_font_get_line_height(icon_font);
+ lv_obj_set_pos(icon, m.number_inset - 2, text_y + (text_h - icon_h) / 2);
+ tx += m.icon - 2 + 5;
+ }
+ auto *name = text(holder, n.name, row_font, theme::INK);
+ lv_obj_set_pos(name, tx, text_y);
+ lv_obj_set_width(name, std::max(20, card_w - tx - m.number_inset - 52));
+ auto *value = text(holder, percent_text(percent_of(n.value, n.low, n.high)), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT);
+ lv_obj_set_width(value, 50);
+ lv_obj_set_pos(value, card_w - m.number_inset - 50, text_y);
+ // Corners, handle and shortest fill as the colour card's brightness slider: the fill keeps the track's radius
+ // (a smaller one makes LVGL draw it into a buffer of its own on every redraw).
+ const int track_x = m.number_inset, track_w = card_w - 2 * m.number_inset, track_h = m.track_h, track_y = m.number_h - m.number_inset - track_h + 2;
+ auto *slider = lv_slider_create(holder);
+ lv_obj_remove_style_all(slider);
+ lv_obj_remove_flag(slider, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_pos(slider, track_x, track_y); lv_obj_set_size(slider, track_w, track_h);
+ lv_obj_set_ext_click_area(slider, m.width >= 480 ? 12 : 8);
+ const int corner = track_h * 12 / 42;
+ lv_obj_set_style_radius(slider, corner, LV_PART_MAIN);
+ lv_obj_set_style_radius(slider, corner, LV_PART_INDICATOR);
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_MAIN);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_TINT), LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_INDICATOR);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_BRIGHT), LV_PART_INDICATOR);
+ const int handle = m.knob, back = std::max(1, track_h / 8) + handle / 2, half = track_h >> 1;
+ lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_KNOB);
+ lv_obj_set_style_bg_color(slider, theme::color(theme::KNOB), LV_PART_KNOB);
+ lv_obj_set_style_radius(slider, 2, LV_PART_KNOB);
+ lv_obj_set_style_pad_left(slider, back + handle / 2 - half, LV_PART_KNOB);
+ lv_obj_set_style_pad_right(slider, handle / 2 - back - (track_h - half), LV_PART_KNOB);
+ lv_obj_set_style_pad_top(slider, -(track_h / 4), LV_PART_KNOB);
+ lv_obj_set_style_pad_bottom(slider, -(track_h / 4), LV_PART_KNOB);
+ lv_slider_set_range(slider, 0, 100);
+ lv_slider_set_value(slider, percent_of(n.value, n.low, n.high), LV_ANIM_OFF);
+ NumberDrawn drawn; drawn.slider = slider; drawn.value = value; drawn.entity = n.entity; drawn.low = n.low; drawn.high = n.high; drawn.step = n.step;
+ numbers.push_back(std::move(drawn));
+ lv_obj_add_event_cb(slider, slider_event, LV_EVENT_ALL, reinterpret_cast(static_cast(i)));
+ }
+ }
+}
+// A state of the light while its page is open: rows follow Home Assistant, a row just chosen keeps its choice a
+// moment, a slider under a finger is left alone.
+inline void updated(const Tile &t) {
+ if (!root || t.entity != entity) return;
+ const uint32_t moment = clock();
+ for (auto &row : rows) {
+ std::string current;
+ if (row.light) current = t.extra().effect;
+ else for (auto &r : t.extra().option_rows) if (r.entity == row.entity) current = r.current;
+ if (!row.sent.empty() && current != row.sent && moment - row.sent_at < SENT_HOLD_MS) continue;
+ if (!row.sent.empty() && (current == row.sent || moment - row.sent_at >= SENT_HOLD_MS)) row.sent.clear();
+ if (row.value) lv_label_set_text(row.value, row_text(current).c_str());
+ }
+ for (auto &n : numbers) {
+ if (!n.slider || lv_obj_has_state(n.slider, LV_STATE_PRESSED) || n.dirty) continue;
+ for (auto &r : t.extra().number_rows) if (r.entity == n.entity) {
+ const int percent = percent_of(r.value, r.low, r.high);
+ if (lv_slider_get_value(n.slider) != percent) { lv_slider_set_value(n.slider, percent, LV_ANIM_OFF); preview(n); }
+ }
+ }
+}
+inline void open(const std::string &light) {
+ const Tile *t = tile_of ? tile_of(light) : nullptr;
+ if (!t || !available(*t)) return;
+ close_picker();
+ entity = light;
+ if (!root) root = page_root();
+ lv_obj_move_foreground(root);
+ draw();
+ ESP_LOGI("effects", "Effects page of %s: %u rows, %u sliders", light.c_str(), (unsigned) rows.size(), (unsigned) numbers.size());
+}
+inline void close() {
+ close_picker();
+ rows.clear(); numbers.clear();
+ options = Options{};
+ if (root) { lv_obj_delete(root); root = nullptr; }
+ entity.clear();
+}
+// A change of look while the page is open: drawn again in place, the picker closed.
+inline void restyle() {
+ if (!root) return;
+ close_picker();
+ lv_obj_set_style_bg_color(root, theme::color(theme::PAGE_SOFT), 0);
+ draw();
+}
+} // namespace effects_page
+#endif
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index 1c46e62..876b6a6 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -92,7 +92,15 @@ struct Choice {
// What only some tiles carry: climate modes, a select's options, weather, sun and timer times, a media
// title, the vacuum rows. A light or a sensor has none of it, so a tile holds this block only while its
// state needs one: twenty tiles with these fields inline took 24 KB of the CYD's RAM, mostly empty.
+// A light's effects page (firmware 0.2.70+): a select entity of the light's device with what it is set to and how many
+// options it has, and a number entity with its range; the names and icons are Home Assistant's, through the add-on.
+struct OptionRow { std::string entity, name, current; uint16_t count = 0; uint32_t icon = 0; };
+struct NumberRow { std::string entity, name; float value = NAN, low = 0, high = 100, step = 1; uint32_t icon = 0; };
struct Extra {
+ // The effect a light runs (its `effect` attribute), and the rows of its effects page.
+ std::string effect;
+ std::vector option_rows;
+ std::vector number_rows;
// Climate: the modes as JSON lists, the current fan and swing mode, and what it is doing now.
std::string hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode, hvac_action;
// A select's options, at most eight.
@@ -133,7 +141,7 @@ struct Extra {
remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() &&
media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() &&
- action_templates.empty() && state_word.empty();
+ action_templates.empty() && state_word.empty() && effect.empty() && option_rows.empty() && number_rows.empty();
}
};
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index e4e256d..22c6c52 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -9,6 +9,7 @@
#include "esphome/core/preferences.h"
#include "cyd_ui.h"
#include "light_controls.h"
+#include "effects_page.h"
#include "tile_controls.h"
#include "history_view.h"
#include "camera_view.h"
@@ -194,6 +195,8 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
return s.valid();
}
inline std::function detail, detail_update;
+// One page of a picker's names (op "options", app 0.2.83+), for the light's effects page.
+inline std::function &&)> options_received;
struct Widgets {
lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{};
int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{};
@@ -486,6 +489,20 @@ inline std::string receive(const std::string &payload) {
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
+ if (op == "options") {
+ // The names a picker on a light's effects page asked for (options_request), one page per message.
+ std::string entity = string(root["e"], 120);
+ if (!valid_entity(entity) || !root["o"].is()) return false;
+ std::vector names;
+ for (JsonVariant name : root["o"].as()) {
+ if (names.size() == effects_page::MAX_NAMES) break;
+ std::string text = string(name, 48);
+ if (!text.empty()) names.push_back(std::move(text));
+ }
+ if (options_received) options_received(entity, root["i"] | 0u, root["n"] | 1u, std::move(names));
+ 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`
@@ -644,6 +661,25 @@ inline std::string receive(const std::string &payload) {
};
if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(next); }
for (auto &[kind, value] : tapped) if (auto *c = next.choice(kind)) if (c->current != value) c->sent = value;
+ // A light's effects page (app 0.2.83+): the effect it runs, and the selects and numbers of its device.
+ next.effect = string(a["effect"], 48);
+ if (extra["rows"].is()) for (JsonVariant r : extra["rows"].as()) {
+ if (next.option_rows.size() == 3) break;
+ OptionRow row; row.entity = string(r["e"], 120);
+ if (!valid_entity(row.entity)) continue;
+ row.name = string(r["n"], 32); row.current = string(r["s"], 48); row.count = static_cast(std::min(r["c"] | 0u, 65535u));
+ row.icon = tile_icon::codepoint(string(r["i"], 8));
+ next.option_rows.push_back(std::move(row));
+ }
+ if (extra["nums"].is()) for (JsonVariant r : extra["nums"].as()) {
+ if (next.number_rows.size() == 2) break;
+ NumberRow row; row.entity = string(r["e"], 120);
+ if (!valid_entity(row.entity)) continue;
+ row.name = string(r["n"], 32); row.value = number(r["v"]); row.low = number(r["lo"], 0); row.high = number(r["hi"], 100); row.step = number(r["st"], 1);
+ if (!(row.high > row.low)) continue;
+ row.icon = tile_icon::codepoint(string(r["i"], 8));
+ next.number_rows.push_back(std::move(row));
+ }
if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]);
next.charging = extra["chg"].is() && extra["chg"].as() == 1;
next.room = string(extra["room"], 32);
@@ -785,6 +821,25 @@ inline void perform(const Tile &tile) {
ESP_LOGI("runtime_action", "Sent service=%s entity=%s (%u values)", x.action.c_str(), tile.entity.c_str(),
(unsigned) (x.action_data.size() + x.action_templates.size()));
}
+// A picker on a light's effects page asks the manager for its names (app 0.2.83+ answers with op "options"), one
+// page at a time. An event, like history_request.
+inline void options_request(const std::string &entity, unsigned page) {
+ if (inbox.empty()) return;
+ esphome::api::HomeassistantActionRequest request;
+ request.service = esphome::StringRef("esphome.screen_options");
+ request.is_event = true;
+ const std::string number = std::to_string(page);
+ const std::string keys[] = {"inbox", "entity", "page"}, values[] = {inbox, entity, number};
+ request.data.init(3);
+ for (int i = 0; i < 3; ++i) {
+ esphome::api::HomeassistantServiceMap entry;
+ entry.key = esphome::StringRef(keys[i]);
+ entry.value = esphome::StringRef(values[i]);
+ request.data.push_back(entry);
+ }
+ esphome::api::global_api_server->send_homeassistant_action(request);
+ ESP_LOGI("effects", "Asked for the names of %s, page %u", entity.c_str(), page);
+}
// A detail card asks the manager for its history (app 0.2.59+ answers with op "history"). An event, like
// setting_event: it needs no permission to call Home Assistant actions.
inline void history_request(const std::string &entity, uint32_t hours) {
@@ -3066,6 +3121,7 @@ inline void render_slot(size_t slot) {
else if (t.is_page()) value = "Page " + std::to_string(t.page_target());
else if (!fresh() || !t.available()) value = "Unavailable";
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused";
+ else if (d == "light" && t.state == "on" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect;
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast(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;
diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h
index 83360ff..3ed7ab9 100644
--- a/components/smart_display/tile_controls.h
+++ b/components/smart_display/tile_controls.h
@@ -350,6 +350,14 @@ inline void settle_suction(runtime_tiles::Extra &x) {
}
if (row) row->current = x.fan_speed;
}
+// Whether a light runs an effect worth naming on its tile (firmware 0.2.70+): WLED's "Solid" is its plain colour, a Hue's
+// "off" and Home Assistant's "None" mean no effect.
+inline bool effect_running(const std::string &effect) {
+ std::string lower;
+ for (char c : effect) lower += static_cast(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c);
+ while (!lower.empty() && lower.back() == ' ') lower.pop_back();
+ return !lower.empty() && lower != "solid" && lower != "off" && lower != "none" && lower != "unknown" && lower != "unavailable";
+}
// The service call behind a chip: a select option on the device, or the vacuum's own fan speed.
inline Action choice_action(const Tile &t, char kind, const std::string &value) {
if (kind == 's') return {"vacuum.set_fan_speed", "fan_speed", value};
diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h
index 4911a4a..be1d22d 100644
--- a/components/smart_display/tile_icon_names.h
+++ b/components/smart_display/tile_icon_names.h
@@ -294,6 +294,11 @@ inline constexpr Named NAMES[] = {
{"speaker-pause", 0xF1B73},
{"speaker-play", 0xF1B72},
{"speedometer", 0xF04C5},
+ {"creation", 0xF0674},
+ {"palette-outline", 0xF0E0C},
+ {"playlist-play", 0xF0411},
+ {"play-speed", 0xF08FF},
+ {"brightness-6", 0xF00DF},
{"spoon-sugar", 0xF1429},
{"sprout-outline", 0xF0E67},
{"square", 0xF0764},
diff --git a/docs/TEST_RESULTS_0283.md b/docs/TEST_RESULTS_0283.md
new file mode 100644
index 0000000..e674125
--- /dev/null
+++ b/docs/TEST_RESULTS_0283.md
@@ -0,0 +1,64 @@
+# Test results app 0.2.83 / firmware 0.2.70 (2026-09-18)
+
+Max asked how hard it is to choose a WLED lamp's modes on the screen ("daar heb je een mega lijst aan modes"), said the
+list is dynamic and differs per lamp, wanted it as LVGL/ESPHome-native as the documentation allows, and then added the
+other WLED things: presets "en die andere". From six questions on a design page he chose: the sparkles key at the top
+right of the colour card (1A), LVGL's roller as the picker for every row (3B), the rows as drawn without preset chips,
+effects alphabetically with Solid on top, English names from Home Assistant's own translations, and the effect's name
+on the tile while one runs. Mid-build he asked for an explicit confirmation: the check key at the top right of the
+picker, in the same slot; the back key sends nothing.
+
+## What changed
+
+- App: `light_effects.py` (the rows of the light's device, the names and icons Home Assistant gives them, the picker's
+ names and their pages), `ATTRS` carries `effect`, `extras()` builds `x.rows` and `x.nums` for a light, the manager
+ watches the device's selects and numbers with the light, subscribes to `esphome.screen_options` and answers with
+ `op: options` (`card_options_loop`), and five firmware-only glyphs join `tile_icons.FIXED` (creation, palette-outline,
+ playlist-play, play-speed, brightness-6). Nothing about WLED is hardcoded beyond a preferred order of translation keys.
+- Firmware: `effects_page.h` (the page, the picker, the sliders; the model part is LVGL-free), `Extra` gained `effect`,
+ `option_rows` and `number_rows`, the state parser reads them, the tile's value line names a running effect
+ (`tile_controls::effect_running`), `runtime_tiles::options_request` asks for a picker's names and `op: options`
+ answers reach `effects_page::received`. Both profiles: a hidden `roller_seed` so ESPHome compiles LVGL's roller, the
+ `color_effects_button` on the colour card, the page's hooks at boot, and its close on dismiss, redraw and live updates.
+- Docs: README pictures (`guition-effects.png`, `guition-effects-picker.png`, `cyd-effects.png`, rendered by the host
+ program), a bullet in README_EXTENDED, the CHANGELOG.
+
+## Automated
+
+- `tools/check.sh`: Python 463 tests (`tests/test_light_effects.py` is new: rows, names, exclusions, caps, sorting,
+ pages under 4096 bytes, who may ask), C++ 19/19 (`tests/test_effects_page.cpp` is new: what has a page, percent and
+ value mapping, texts, both boards' sizes; `effect_running`), packages, icons, Vitest, vue-tsc, build and the bundle.
+- `tools/check.sh --firmware` (ESPHome 2026.6.2): CYD 1,675,424 B = 91.3 % of the 1,835,008 B slot, +20,288 B against
+ 0.2.82 (tight band: LVGL's roller is compiled in for the first time, the page's code, and five glyphs in three icon
+ fonts), Guition well under its 8 MB slot. The first ESP32 compile caught the usual trap the host build cannot:
+ `std::max(0, int - lv_font_get_line_height())` needs an `(int)` cast (int32_t is long there).
+
+## On the host (the real firmware for the Mac, SDL, own API ports 6231/6232)
+
+`effects_host.py` builds each board's package as a host program with probe actions, sends a layout of three lights (a
+WLED, a plain light, a Hue with its candle effect) whose messages the real add-on code builds
+(`core.extras` + `state_message`), answers the screen's `esphome.screen_options` events with the real
+`light_effects.options_for/pages/message`, and checks 35 steps per board, 35/35 on the Guition and 35/35 on the CYD:
+
+- The WLED tile reads "TV Simulator", the plain light "59 %", the Hue "candle".
+- The colour card of the WLED shows the sparkles key; a plain light's card has none; a Hue with effects has one.
+- The page shows Effect, Color palette, Preset and Playlist with Home Assistant's values (a dash for unknown), Speed
+ and Intensity at 50 %, everything inside the screen on both boards.
+- A row asks for its names (`esphome.screen_options` with inbox, entity and page 0) and says "Loading..." meanwhile; the
+ add-on answers one page with Solid first; the roller shows all 216 and rests on the current effect.
+- Turning the drum sends nothing; the check key sends one `light.turn_on` with the effect and closes the picker; the row
+ says the choice at once, a stale state does not flip it back, the reported effect settles it; opening the picker again
+ uses the names it still holds and confirming the current effect sends nothing.
+- A second LVGL pointer drags the drum without sending; the check sends the effect it stands on.
+- The palette row asks for the select's options, lists them in Home Assistant's order, rests on Aurora, and a choice
+ goes out as `select.select_option`; the back key of the picker sends nothing.
+- A slider release sends `number.set_value` on the entity's own scale (80 % of 0-255 is 204); the label follows the
+ finger; a reported value keeps the slider, a value changed elsewhere moves it.
+- Dark mode draws the page again; closing the page returns to the colour card; closing the cards closes the page too.
+- Renders of every state on both boards, light and dark, are what the README shows.
+
+## Not tested
+
+- On real hardware: the roller under a real finger (GT911 and XPT2046), the keys, and the answer path through a live
+ Home Assistant with a real WLED. Max's Guition Wallbox at home and his VUELTA are the place for that.
+- A select with more names than one message holds (the paging is covered by the Python tests only).
diff --git a/docs/images/cyd-effects.png b/docs/images/cyd-effects.png
new file mode 100644
index 0000000000000000000000000000000000000000..0fbd6130f88720f481460d000e10e10cfd4e735e
GIT binary patch
literal 13740
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[4*ivpTUE3w2gS}Gwco}kyA3tRb8jRxXwEWq2?-5stHm{<`;ABlG;)*w)M53
z@bi4edG3dRh@JbJJPnN8nddam`koAl;HLuX*B!@An}{M1vR+iUzJZ-cLu>qXR^I(yVYpG-xv9aRtt+;KLgK
z0|?;4>VXW~tkx+fSfriX6^FBb;`qF_Vu%W)gZ$j6A@J-xk~az&OMnoarfy&`2?24m
zC-;v%U(?g~8K`+lPKY>oSEwvdthCaIwGjrc4v);@G;-FHgO0|UH6jr8Np>$nom9ou
zn{ve_7#tvu2a|%X<~8lljbI2^mYA7n=QNqY0)U8KT&<}c9%#hS>iwuKEyJx#I&@@T
zXV%@Ije-wBpvx5i_GQ>5i(XdiheFI4W^J~yG(BDUG7PF7@B{QhFe`y+K<;VzTSe+U
zXi%X&kg94FX?Wn;3Md*(<)*@%ETNN`_~ER`ThPgP!LX7S?-W]u%u9Axe@C`7s8<|uwwF~xqo%rSUmX&KH@g__qYk~g1+vx;$EDH+EDX!5
z2ZO}Fz3C%bA?;fFZj-jBNHsmUq{3+5QPB{Iv_o5t*K7}~cO(qaTiCY#N#Twj64mEm
zIak=9j3l?APVS6H$v7}Y3P@kaHcm*fKd{x*PEcobwcxCh>S`Zsl7Q<$kD4VR^ZE-_
zE-ZZux?_}J<+|J+V$4AoktWab+U-S=PZI;z(Qn<!IbS9|t{%_r{h(QN>P`gFgT|
z013M!9mO#Ts1-Mz=*Z
zQ>FSF4dX;V2`vT{dp%B5yV&w~AK!~w5D!__D!7ehWE8o#8`DjOhHLYSt|@v<(m%g^
zirzH<+K|20>vl1U6tS;~F7<4<$NT84$_xA{f+;mh%dhAU`um@ybAer2TERNI*kM7i
zVrcB9l5A%PCN+*?+q)Dg0bsUwgWLM>Ws0FmX-T+%gyB>yr6us?)Wb8@;-AUqaHZ+j
z7K7X#{(2VQj~4+zFHY`A%)si%&sUUocKyv&C9lyYHv@eR=(tf#T#*
z56SL)Am0d;4lwU}W{%osa}?jBQ{@qolb6Yvf(_g>X?bGW^NQor0Dm3F#qfH%TGC!R
z*88Fe_Q45NbXy&YlrDHt=C9(j&DYgHj~-cxtkhCkdqDCmz17txHNms@i?N_d?O{TO
z)U*vAPKGI>r`&I+X6%`QM~jNO!_#;B;qBho*(&4q
z)uIgZS(=DJF!bGF>6Z!$WCc7l{q~#LRm)+AD*W``#qBP!#mq97xK5F!L#V#MIi!b5
zmvddfWON*+l>YeKXH|3uyUed7jH@729H=Rfg#%bAy=x12OE0}DPy%O9_kKx{5A^{R1c
zSIUJ27A==o`Oo(cc6(L-0c!i{)d+IA)Aaoz9u%w91@?`bV7zc|7qhrW-?yb4OOR0F
z2RGfEwmQ*L(0;b9sJ&$>Y44&m4#aUzODD^!|2&7x&~nSxXCBjz$~Ua*UgGTSIUQJ(@-%^X%Xz5t>+-;`%w%U0`
zktsiz&qRS3@zN^iH)v?U|FtcacWrs61wlIM2J4K4;eM^TneJ3o*@v?eG|EQM^agAE
z#UT2}u%|_c{u@mMUf-3mBQEcR82THXlV`@F)`xkawYd%TcZpqRg(8Dj8QAs0Vg6Ke
ziSF~Aok6WZCf`>T3|_1}`v%`2g|W(ME;NEXJ?q+!niDwn!nu5X1euTj;acK*&Hidt
ze56@mendCFksMBqP&43vW^CrG2->J$S$3f7Q0|-?kzQ
z?7XyUF*l<(j(BvXeyg(|-lxoSfJ?@~pJMR4&U}7(tB&B{o~1XYv7Gg-XZnyvq;EcX
zcw>2|U~xSq)I)eC=$cO#=d*WnkV*E?)@jIB73~3k+JZbH>AJyL=XQa93FusDSjg}r
z&^Yl?{3G9*&o|zaxY{+*?bEukK3ou@Qup%m*Tw0mNm7jT(Yrztuo2Mm3YlhW^>9~n
zYT|nhe|QzP0=g4nzGELI?PEn%-A^z%sf|$|`Z9#jed#WadY=+cE%$b`Wu2%IOVbTs
zf|PH3om~E;Nz3}PUbI>~LZ`}2KPPFlolEp0qHrVj|dIwRz4mw5!})a7=BKT+{KM!!u@f%lg+YDZam
z$o`zjB}H1tSfi!_Bj?~Tzl8f1&?30q!RnrdR?8n0`hnwj(Xj0_-lzbqh56O$j$bCS
z)jo=YQyE)j%()8Zxx%-34>iI9*_Z!g9LS@r*uiVtNj#NO+{T_p3BsvBh`tMa0cTq{
dM_Aox!1VYO@y5r2zrunLXU)%)|L%J8e*rUW^T+@I
literal 0
HcmV?d00001
diff --git a/docs/images/guition-effects-picker.png b/docs/images/guition-effects-picker.png
new file mode 100644
index 0000000000000000000000000000000000000000..019faba9638fe6c4160d799edfb05d4f7c18958c
GIT binary patch
literal 16701
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
ZUUz)@{=|}I0eA^P{9u2GbinJ<{{a5HO`ZS%
literal 0
HcmV?d00001
diff --git a/docs/images/guition-effects.png b/docs/images/guition-effects.png
new file mode 100644
index 0000000000000000000000000000000000000000..6efbbb24827591fa0ab4ed2d8b7fd93e726cc2ea
GIT binary patch
literal 21738
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