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 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 21:38:20 +02:00
parent 86e6ce79fe
commit e7e173d3b7
33 changed files with 1409 additions and 17 deletions
+6 -1
View File
@@ -51,12 +51,17 @@ also be changed on the screen or by an automation, and your own YAML for one scr
<img src="docs/images/guition-blind.png" width="32%" alt="Cover card for a venetian blind: its battery, the position slider with the blind hanging from the top, the tilt slider over slats, and open, stop and close">
<img src="docs/images/guition-history-touch.png" width="32%" alt="A finger on the history graph: the top of the card shows the average of that hour and its time, the graph stays as it is">
</p>
<p align="center">
<img src="docs/images/guition-effects.png" width="32%" alt="The effects page of a WLED lamp: the effect, colour palette, preset and playlist rows with what they are set to, and the speed and intensity sliders">
<img src="docs/images/guition-effects-picker.png" width="32%" alt="The effect picker: a drum with every effect Home Assistant lists, TV Simulator in the middle, and the check key at the top right that sends it">
<img src="docs/images/cyd-effects.png" width="32%" alt="The same effects page on the CYD: four rows and the two sliders in 320 by 240 pixels">
</p>
<p align="center">
<img src="docs/images/guition-media.png" width="32%" alt="The media card on the Guition: the album cover, the title, artist and album, a progress bar with the elapsed and total time, previous, pause and next keys and the volume slider">
<img src="docs/images/guition-media-full.png" width="32%" alt="A media player over the whole page: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom">
<img src="docs/images/guition-dark-media.png" width="32%" alt="The media card in dark mode: the same cover and keys on a black page">
</p>
<p align="center"><sub>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 (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
<p align="center"><sub>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 (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
<p align="center">
<img src="docs/images/cyd-home.png" width="32%" alt="CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value">
<img src="docs/images/cyd-page-2.png" width="32%" alt="Second CYD page: Sonos volume, presence, a scene and an energy graph">
+8
View File
@@ -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.
+473
View File
@@ -0,0 +1,473 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
#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<int>(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<std::string> &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<std::string> &names, const std::string &name) {
for (size_t i = 0; i < names.size(); ++i) if (names[i] == name) return static_cast<int>(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 &current) {
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<const Tile *(const std::string &)> tile_of; // a tile by entity
inline std::function<void(const std::string &, const std::string &, const std::string &, const std::string &)> send; // service, entity, key, value
inline std::function<void(const std::string &, unsigned)> ask; // options_request
inline std::function<int()> 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<RowDrawn> rows;
inline std::vector<NumberDrawn> numbers;
// The picker's names: one entity at a time, page by page from the add-on.
struct Options { std::string entity; std::vector<std::string> 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_t>(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<int>(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<uint32_t>(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<std::string> &&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<int>(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<int>(reinterpret_cast<intptr_t>(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<intptr_t>(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<Spec> 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<int>(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<int>(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<void *>(static_cast<intptr_t>(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<int>(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<size_t>(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<int>(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<void *>(static_cast<intptr_t>(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
+9 -1
View File
@@ -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<OptionRow> option_rows;
std::vector<NumberRow> 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.
+56
View File
@@ -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<void(Tile &)> 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<void(const std::string &, unsigned, unsigned, std::vector<std::string> &&)> 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<JsonArray>()) return false;
std::vector<std::string> names;
for (JsonVariant name : root["o"].as<JsonArray>()) {
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<JsonArray>()) for (JsonVariant r : extra["rows"].as<JsonArray>()) {
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<uint16_t>(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<JsonArray>()) for (JsonVariant r : extra["nums"].as<JsonArray>()) {
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<int>() && extra["chg"].as<int>() == 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<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;
+8
View File
@@ -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<char>(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};
@@ -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},
+64
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+62 -1
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.69"
SCREEN_FIRMWARE_VERSION: "0.2.70"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -219,6 +219,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
effects_page::close();
runtime_tiles::hide_detail();
runtime_tiles::camera_close();
id(active_entity).clear();
@@ -296,6 +297,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -319,6 +321,24 @@ esphome:
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); };
// The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
// the ways out: actions to Home Assistant and the question for a picker's names.
effects_page::title_font = id(headline)->get_lv_font();
effects_page::row_font = id(sublabel)->get_lv_font();
effects_page::roller_font = id(sublabel_big)->get_lv_font();
effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
return nullptr;
};
effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
runtime_tiles::action(service, entity, key, value);
};
effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
effects_page::drift = []() { return cyd::touch_guard.distance(); };
effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
effects_page::now = []() { return (uint32_t) millis(); };
runtime_tiles::options_received = effects_page::received;
climate_card::look.card = id(climate_settings_card);
climate_card::look.font = id(label)->get_lv_font();
climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font();
@@ -355,6 +375,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1215,6 +1236,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
- "\U000F0674" # creation
- "\U000F0E0C" # palette-outline
- "\U000F0411" # playlist-play
- "\U000F08FF" # play-speed
- "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1914,6 +1940,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
# Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
# (firmware 0.2.70+).
- roller:
id: roller_seed
hidden: true
width: 2
height: 2
options: ["-"]
# Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
- image:
id: camera_image_seed
@@ -2949,6 +2983,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
# The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
# preset, speed), opens its effects page. Hidden for every other light.
- obj:
id: color_effects_button
align: TOP_RIGHT
x: -16
y: 16
width: 60
height: 60
radius: 999
styles: paint_key
bg_opa: COVER
border_width: 0
hidden: true
clickable: true
scrollable: false
on_click:
- lambda: 'effects_page::open(id(active_entity));'
widgets:
- label:
align: CENTER
text: "\U000F0674"
text_font: materialdesign_icons_mini
clickable: false
- label:
id: color_detail_title
width: 310
@@ -4505,6 +4564,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
+62 -1
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.69"
SCREEN_FIRMWARE_VERSION: "0.2.70"
# 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.
@@ -314,6 +314,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
effects_page::close();
runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -363,6 +364,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -386,6 +388,24 @@ esphome:
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); };
// The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
// the ways out: actions to Home Assistant and the question for a picker's names.
effects_page::title_font = id(headline)->get_lv_font();
effects_page::row_font = id(sublabel_big)->get_lv_font();
effects_page::roller_font = id(sublabel_big)->get_lv_font();
effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
return nullptr;
};
effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
runtime_tiles::action(service, entity, key, value);
};
effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
effects_page::drift = []() { return cyd::touch_guard.distance(); };
effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
effects_page::now = []() { return (uint32_t) millis(); };
runtime_tiles::options_received = effects_page::received;
- lambda: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
@@ -423,6 +443,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1231,6 +1252,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
- "\U000F0674" # creation
- "\U000F0E0C" # palette-outline
- "\U000F0411" # playlist-play
- "\U000F08FF" # play-speed
- "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1879,6 +1905,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
# Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
# (firmware 0.2.70+).
- roller:
id: roller_seed
hidden: true
width: 2
height: 2
options: ["-"]
- label:
id: lbl_room
x: 11
@@ -2849,6 +2883,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
# The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
# preset, speed), opens its effects page. Hidden for every other light.
- obj:
id: color_effects_button
align: TOP_RIGHT
x: -10
y: 8
width: 40
height: 40
radius: 999
styles: paint_key
bg_opa: COVER
border_width: 0
hidden: true
clickable: true
scrollable: false
on_click:
- lambda: 'effects_page::open(id(active_entity));'
widgets:
- label:
align: CENTER
text: "\U000F0674"
text_font: materialdesign_icons_mini
clickable: false
- label:
id: color_detail_title
width: 210
@@ -4318,6 +4377,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
+62 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.69"
SCREEN_FIRMWARE_VERSION: "0.2.70"
# 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.
@@ -318,6 +318,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
effects_page::close();
runtime_tiles::hide_detail();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -367,6 +368,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -390,6 +392,24 @@ esphome:
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); };
// The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
// the ways out: actions to Home Assistant and the question for a picker's names.
effects_page::title_font = id(headline)->get_lv_font();
effects_page::row_font = id(sublabel_big)->get_lv_font();
effects_page::roller_font = id(sublabel_big)->get_lv_font();
effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
return nullptr;
};
effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
runtime_tiles::action(service, entity, key, value);
};
effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
effects_page::drift = []() { return cyd::touch_guard.distance(); };
effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
effects_page::now = []() { return (uint32_t) millis(); };
runtime_tiles::options_received = effects_page::received;
- lambda: |-
if (!runtime_tiles::enabled) return;
screen_calibration::apply = [](const screen_calibration::Calibration &cal) {
@@ -427,6 +447,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1220,6 +1241,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
- "\U000F0674" # creation
- "\U000F0E0C" # palette-outline
- "\U000F0411" # playlist-play
- "\U000F08FF" # play-speed
- "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1868,6 +1894,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
# Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
# (firmware 0.2.70+).
- roller:
id: roller_seed
hidden: true
width: 2
height: 2
options: ["-"]
- label:
id: lbl_room
x: 11
@@ -2838,6 +2872,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
# The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
# preset, speed), opens its effects page. Hidden for every other light.
- obj:
id: color_effects_button
align: TOP_RIGHT
x: -10
y: 8
width: 40
height: 40
radius: 999
styles: paint_key
bg_opa: COVER
border_width: 0
hidden: true
clickable: true
scrollable: false
on_click:
- lambda: 'effects_page::open(id(active_entity));'
widgets:
- label:
align: CENTER
text: "\U000F0674"
text_font: materialdesign_icons_mini
clickable: false
- label:
id: color_detail_title
width: 210
@@ -4307,6 +4366,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
+62 -1
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.69"
SCREEN_FIRMWARE_VERSION: "0.2.70"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -223,6 +223,7 @@ esphome:
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
effects_page::close();
runtime_tiles::hide_detail();
runtime_tiles::camera_close();
id(active_entity).clear();
@@ -300,6 +301,7 @@ esphome:
std::to_string(t.minimum), std::to_string(t.maximum), std::to_string(t.step));
};
runtime_tiles::detail_update = [](runtime_tiles::Tile &t) {
effects_page::updated(t);
if (id(active_entity) != t.entity || id(touch_down)) return;
if (t.domain() == "climate" && !lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) {
if (id(climate_edit_pending) && millis() - id(climate_edit_ms) < 3000) return;
@@ -323,6 +325,24 @@ esphome:
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); };
// The effects page behind the colour card's sparkles key (firmware 0.2.70+): fonts, the tile it shows, and
// the ways out: actions to Home Assistant and the question for a picker's names.
effects_page::title_font = id(headline)->get_lv_font();
effects_page::row_font = id(sublabel)->get_lv_font();
effects_page::roller_font = id(sublabel_big)->get_lv_font();
effects_page::icon_font = id(materialdesign_icons_mini)->get_lv_font();
effects_page::tile_of = [](const std::string &entity) -> const runtime_tiles::Tile * {
for (size_t i = 0; i < runtime_tiles::model.count; ++i) if (runtime_tiles::model.tiles[i].entity == entity) return &runtime_tiles::model.tiles[i];
return nullptr;
};
effects_page::send = [](const std::string &service, const std::string &entity, const std::string &key, const std::string &value) {
runtime_tiles::action(service, entity, key, value);
};
effects_page::ask = [](const std::string &entity, unsigned page) { runtime_tiles::options_request(entity, page); };
effects_page::drift = []() { return cyd::touch_guard.distance(); };
effects_page::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
effects_page::now = []() { return (uint32_t) millis(); };
runtime_tiles::options_received = effects_page::received;
climate_card::look.card = id(climate_settings_card);
climate_card::look.font = id(label)->get_lv_font();
climate_card::look.icon_font = id(materialdesign_icons_mini)->get_lv_font();
@@ -359,6 +379,7 @@ esphome:
theme::redraw = []() {
runtime_tiles::restyle();
light_controls::restyle();
effects_page::restyle();
settings_screen::restyle();
if (id(alert_active)) lv_obj_set_style_bg_color(id(alert_card), lv_color_hex(theme::surface(id(alert_card_color))), LV_PART_MAIN);
if (!lv_obj_has_flag(id(climate_detail_overlay), LV_OBJ_FLAG_HIDDEN)) id(climate_card_refresh).execute();
@@ -1204,6 +1225,11 @@ font:
- "\U000F1B73" # speaker-pause
- "\U000F1B72" # speaker-play
- "\U000F04C5" # speedometer
- "\U000F0674" # creation
- "\U000F0E0C" # palette-outline
- "\U000F0411" # playlist-play
- "\U000F08FF" # play-speed
- "\U000F00DF" # brightness-6
- "\U000F1429" # spoon-sugar
- "\U000F0E67" # sprout-outline
- "\U000F0764" # square
@@ -1903,6 +1929,14 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
# Never shown either: it makes ESPHome compile LVGL's roller, the drum a light's effects page picks from
# (firmware 0.2.70+).
- roller:
id: roller_seed
hidden: true
width: 2
height: 2
options: ["-"]
# Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
- image:
id: camera_image_seed
@@ -2938,6 +2972,31 @@ lvgl:
text_font: materialdesign_icons_mini
clickable: false
# The sparkles key (firmware 0.2.70+): a light with effects, or with modes on its device (a WLED's palette,
# preset, speed), opens its effects page. Hidden for every other light.
- obj:
id: color_effects_button
align: TOP_RIGHT
x: -16
y: 16
width: 60
height: 60
radius: 999
styles: paint_key
bg_opa: COVER
border_width: 0
hidden: true
clickable: true
scrollable: false
on_click:
- lambda: 'effects_page::open(id(active_entity));'
widgets:
- label:
align: CENTER
text: "\U000F0674"
text_font: materialdesign_icons_mini
clickable: false
- label:
id: color_detail_title
width: 310
@@ -4494,6 +4553,8 @@ script:
- lambda: |-
lv_label_set_text(id(color_detail_title), id(active_title).c_str());
light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct));
if (effects_page::offered(id(active_entity))) lv_obj_remove_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(color_effects_button), LV_OBJ_FLAG_HIDDEN);
- lvgl.widget.hide: brightness_overlay
- lvgl.widget.show: color_detail_overlay
+9
View File
@@ -1,3 +1,12 @@
## 0.2.83 (firmware 0.2.70)
A lamp's modes on the screen: effects, palettes and presets of a WLED, and the effect of any light that has one.
- **An effects page behind the light's colour card.** 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 one row per thing the lamp offers: the light's *Effect*, and the select entities on the lamp's device (a WLED's *Color palette*, *Preset* and *Playlist*), each with what it is set to. Under them a slider per number entity of the device (a WLED's *Speed* and *Intensity*). Nothing is hardcoded for a brand: the rows, their English names and their icons are what Home Assistant lists for the device, so another lamp shows its own rows and a lamp without any shows none.
- **A picker that turns like a drum.** A row opens LVGL's roller with every name Home Assistant has at that moment, the effects alphabetically with *Solid* on top and a select's options in Home Assistant's order. Turn it to a name and press the check at the top right: that sends one action (`light.turn_on` with the effect, `select.select_option`; a slider sends `number.set_value` when you let go), the row shows the choice at once, and the back key sends nothing. The list is asked for when the picker opens (event `esphome.screen_options`, answered with `op: options`, one page of names per message), so the screen holds no list while it is closed and a WLED update shows its new effects the next time.
- **The tile names the effect.** A light tile reads *TV Simulator* instead of *100 %* while an effect runs; *Solid*, *off* and *None* count as none.
- Needs firmware 0.2.70: press **Update** on the screen. Includes everything from 0.2.82. CYD firmware: 1,675,424 bytes, 91.3 % of the update slot (20,288 bytes more than 0.2.82: LVGL's roller is compiled in for the first time, plus the page and five icons).
## 0.2.82 (firmware 0.2.69)
The page buttons can make way, and the tiles fill the screen.
+10 -4
View File
@@ -37,7 +37,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.69'
FIRMWARE_VERSION = '0.2.70'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -50,7 +50,7 @@ SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
DARK_MODE_MIN_FIRMWARE = '0.2.54'
# Page buttons: the Previous and Next bar under the tiles, a setting and entity of its own; off, the tiles take its room.
PAGE_BUTTONS_MIN_FIRMWARE = '0.2.69'
ATTRS = frozenset('brightness percentage current_position current_tilt_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())
ATTRS = frozenset('brightness percentage current_position current_tilt_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 effect'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -1131,10 +1131,16 @@ def media_extras(attrs):
return result or None
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device)."""
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None, entries=None, words=None, icon_of=None, device_name=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device, the rows of
a light's effects page)."""
domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'light':
# The effects page (app 0.2.83): the select and number entities of the light's device, named as Home Assistant
# names them; the effect itself travels as the `effect` attribute.
import light_effects
return light_effects.rows(tile['entity'], device, states, entries, words, icon_of, device_name) or None
if domain == 'vacuum':
return vacuum_extras(tile, states, device)
if domain == 'cover':
+210
View File
@@ -0,0 +1,210 @@
"""A light's effects page (app 0.2.83 / firmware 0.2.70): what the screen shows for a lamp with modes, such as a WLED.
Home Assistant is the source of every name and list here. The light's own `effect_list` is one row; the select and
number entities on the light's device (WLED: color palette, preset, playlist, speed, intensity) are the others, named
as Home Assistant names them in English (frontend/get_translations `entity`) with the icons its frontend shows. Nothing
about a brand is hardcoded: a lamp with only an effect list gets one row, a lamp without any gets no page.
The current values travel in the tile's state message (`x.rows`, `x.nums`); the lists themselves are asked for when a
picker opens (`esphome.screen_options` -> `op: options`), one page per message, so the screen holds no list while the
picker is closed and always shows what Home Assistant has at that moment.
"""
import json
# LightEntityFeature.EFFECT
EFFECT_FEATURE = 4
# Rows the effects page has room for: the effect plus three selects, and two numbers (a Guition shows them all).
SELECT_ROWS = 3
NUMBER_ROWS = 2
# Where these translation keys occur they come first, in this order; everything else follows by name. This is
# presentation only: the rows themselves still come from the device.
PREFERRED_SELECTS = ('color_palette', 'preset', 'playlist')
PREFERRED_NUMBERS = ('speed', 'intensity')
# A message to the screen is at most 4096 bytes; the names of one page stay well under it.
PAGE_BYTES = 3400
NAME_LIMIT = 48
OPTIONS_EVENT = 'esphome.screen_options'
def supports_effects(attrs):
"""Whether the light offers effects: Home Assistant's EFFECT feature and a list to choose from."""
features = (attrs or {}).get('supported_features')
return isinstance(features, int) and bool(features & EFFECT_FEATURE) and isinstance((attrs or {}).get('effect_list'), list)
def effect_options(attrs):
"""The light's effects as the picker lists them: alphabetically, with "Solid" (WLED's plain colour) first."""
names = [str(v) for v in (attrs or {}).get('effect_list') or [] if isinstance(v, (str, int, float)) and not isinstance(v, bool)]
names = [n for n in names if n.strip()]
solid = [n for n in names if n.strip().lower() == 'solid']
rest = sorted((n for n in names if n.strip().lower() != 'solid'), key=lambda n: (n.casefold(), n))
return solid[:1] + rest
def _numeric(options):
"""A select whose every option is a bare number (WLED's live override: 0, 1, 2) is a technical setting, not a mode."""
return all(isinstance(o, str) and o.strip().lstrip('-').isdigit() for o in options)
def _qualifies(item, states):
"""A select or number of the device the page can show: enabled, not hidden, not diagnostic."""
eid = item.get('entity_id') or ''
if item.get('disabled_by') or item.get('hidden_by') or item.get('entity_category') == 'diagnostic':
return False
state = states.get(eid)
if not isinstance(state, dict):
return False
attrs = state.get('attributes') or {}
if eid.startswith('select.'):
options = attrs.get('options')
return isinstance(options, list) and bool(options) and all(isinstance(o, str) for o in options) and not _numeric(options)
if eid.startswith('number.'):
try:
return float(attrs.get('min')) < float(attrs.get('max'))
except (TypeError, ValueError):
return False
return False
def _order(items, preferred, names):
def rank(item):
key = item.get('translation_key') or ''
return (preferred.index(key) if key in preferred else len(preferred), names[item['entity_id']].casefold())
return sorted(items, key=rank)
def entity_name(eid, entry, states, words):
"""Home Assistant's English name of a device's entity: its integration's translation for the entity's key
(component.wled.entity.select.color_palette.name = "Color palette"), else the name Home Assistant shows, without the
device's name in front of it ("VUELTA Kleurenpalet" is "Kleurenpalet")."""
entry = entry if isinstance(entry, dict) else {}
domain = eid.split('.', 1)[0]
if isinstance(words, dict) and entry.get('platform') and entry.get('translation_key'):
word = words.get(f"component.{entry['platform']}.entity.{domain}.{entry['translation_key']}.name")
if isinstance(word, str) and word and '{' not in word:
return word
name = entry.get('name') or entry.get('original_name') or (states.get(eid, {}).get('attributes') or {}).get('friendly_name') or eid
return str(name)
def strip_device_name(name, device_name):
""""VUELTA Kleurenpalet" without the device's name, as Home Assistant's device page shows it."""
if isinstance(device_name, str) and device_name and name.lower().startswith(device_name.lower() + ' '):
return name[len(device_name) + 1:] or name
return name
def siblings(entity, device, states):
"""The select and number entities on the light's device that its effects page shows, in page order."""
selects, numbers = [], []
for item in device or ():
eid = item.get('entity_id') or ''
if eid == entity or not _qualifies(item, states):
continue
(selects if eid.startswith('select.') else numbers).append(item)
return selects, numbers
def related(entity, device, states):
"""Entity ids the light's card reads besides its own, for the manager's watch list."""
selects, numbers = siblings(entity, device, states)
return tuple(item['entity_id'] for item in selects + numbers)
def rows(entity, device, states, entries=None, words=None, icon_of=None, device_name=None):
"""The `rows` (selects) and `nums` (numbers) of the light's effects page for the tile's extra block, or {}.
`entries` is the registry by entity id (names and translation keys), `words` Home Assistant's English entity
translations, `icon_of(eid, state, attributes, entry)` the codepoint of the icon Home Assistant shows, or None."""
selects, numbers = siblings(entity, device, states)
entries = entries or {}
names = {}
for item in selects + numbers:
eid = item['entity_id']
entry = entries.get(eid, item)
names[eid] = strip_device_name(entity_name(eid, entry, states, words), device_name)
result = {}
found = []
for item in _order(selects, PREFERRED_SELECTS, names)[:SELECT_ROWS]:
eid = item['entity_id']
state = states[eid]
attrs = state.get('attributes') or {}
row = {'e': eid, 'n': _short(names[eid], 32), 's': _short(state.get('state') if isinstance(state.get('state'), str) else '', NAME_LIMIT),
'c': len(attrs.get('options') or [])}
icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
if icon:
row['i'] = icon
found.append(row)
if found:
result['rows'] = found
found = []
for item in _order(numbers, PREFERRED_NUMBERS, names)[:NUMBER_ROWS]:
eid = item['entity_id']
state = states[eid]
attrs = state.get('attributes') or {}
row = {'e': eid, 'n': _short(names[eid], 32), 'lo': _number(attrs.get('min'), 0), 'hi': _number(attrs.get('max'), 100),
'st': _number(attrs.get('step'), 1)}
value = _number(state.get('state'), None)
if value is not None:
row['v'] = value
icon = icon_of(eid, state.get('state'), attrs, entries.get(eid, item)) if icon_of else None
if icon:
row['i'] = icon
found.append(row)
if found:
result['nums'] = found
return result
def options_for(entity, states, layout_entities=(), device_of=None):
"""The names a picker lists for `entity`, or None when the screen may not ask for them: a light on the layout (its
effects) or a select on the device of such a light (its options). `device_of(light)` gives that device's entries."""
state = states.get(entity)
if not isinstance(state, dict):
return None
attrs = state.get('attributes') or {}
if entity.startswith('light.'):
return effect_options(attrs) if entity in layout_entities and supports_effects(attrs) else None
if entity.startswith('select.'):
for light in layout_entities:
if light.startswith('light.') and device_of and entity in related(light, device_of(light), states):
options = attrs.get('options')
return [str(o) for o in options if isinstance(o, str)] if isinstance(options, list) else None
return None
def pages(names):
"""The names cut into pages whose message stays under the screen's limit (216 WLED effects are one page)."""
result, page, size = [], [], 0
for name in names:
name = _short(name, NAME_LIMIT)
cost = len(json.dumps(name, ensure_ascii=False).encode()) + 1
if page and size + cost > PAGE_BYTES:
result.append(page)
page, size = [], 0
page.append(name)
size += cost
if page or not result:
result.append(page)
return result
def message(entity, page, all_pages):
"""One page of a picker's names, as the screen parses it (`op: options`)."""
page = max(0, min(int(page), len(all_pages) - 1))
return {'v': 1, 'op': 'options', 'e': entity, 'i': page, 'n': len(all_pages), 'o': all_pages[page]}
def _short(value, limit):
value = str(value if value is not None else '')
return value if len(value) <= limit else value[:limit - 1] + '…'
def _number(value, fallback):
try:
number = float(value)
except (TypeError, ValueError):
return fallback
if number != number or number in (float('inf'), float('-inf')):
return fallback
return int(number) if number.is_integer() else round(number, 3)
+59 -3
View File
@@ -14,6 +14,7 @@ import camera_feed
import claude_skill
from firmware import Firmware
import ha_catalogue
import light_effects
import tile_icons
from updates import Updater
@@ -25,6 +26,11 @@ import header_bar
import history_card
from zoneinfo import ZoneInfo
def row_icon(eid, state, attrs, entry):
"""Codepoint of the icon Home Assistant shows for a row of a light's effects page, when the screen's fonts carry it."""
return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(eid, state, attrs, entry)
LOG = logging.getLogger('screen_manager')
REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated')
@@ -124,6 +130,8 @@ class HomeAssistant:
self.history_requests = asyncio.Queue()
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
self.camera_requests = asyncio.Queue()
# The names a light's picker lists (firmware 0.2.70+): {inbox, entity, page}, for Manager.card_options_loop.
self.options_requests = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -161,6 +169,8 @@ class HomeAssistant:
self.history_requests.put_nowait(body)
elif event.get('event_type') == 'esphome.screen_camera':
self.camera_requests.put_nowait(body)
elif event.get('event_type') == light_effects.OPTIONS_EVENT:
self.options_requests.put_nowait(body)
elif event.get('event_type') == 'state_changed':
eid = body.get('entity_id')
if body.get('new_state'):
@@ -295,6 +305,7 @@ class HomeAssistant:
await self.request('subscribe_events', event_type='esphome.screen_setting')
await self.request('subscribe_events', event_type='esphome.screen_history')
await self.request('subscribe_events', event_type='esphome.screen_camera')
await self.request('subscribe_events', event_type=light_effects.OPTIONS_EVENT)
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
@@ -818,6 +829,37 @@ class Manager:
self.retries[inbox] = failures + 1
self.retry_at[inbox] = due
# ----- The names a light's picker lists (app 0.2.83, firmware 0.2.70+) -----
async def card_options_loop(self):
"""Answer a screen that opens a picker on a light's effects page: the effects of the light, or the options of a
select on its device, one page per message."""
while True:
request = await self.ha.options_requests.get()
try:
await self.answer_options(request)
except (ClientError, ConnectionError, TimeoutError, OSError, ValueError) as error:
LOG.info('No options for %s (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
async def answer_options(self, request):
"""One screen's request: a light on its own layout, or a select on that light's device; a page it asked for."""
if not isinstance(request, dict):
return
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
entity = request.get('entity')
try:
page = int(request.get('page') or 0)
except (TypeError, ValueError):
page = 0
layout, screen = self.layouts.get(inbox), self.screen(inbox) if isinstance(inbox, str) else None
if not isinstance(entity, str) or not layout or not screen or not screen.get('online'):
return
names = light_effects.options_for(entity, self.ha.states, [tile['entity'] for tile in layout['tiles']], self.device_entries)
action = self.transport(inbox, screen)
if names is None or not action:
return
all_pages = light_effects.pages(names)
await self.ha.send(inbox, light_effects.message(entity, page, all_pages), action)
# ----- History on a detail card (firmware 0.2.51+) -----
async def card_history_loop(self):
"""Answer the history a screen asks for when a card opens, a few at a time."""
@@ -918,8 +960,18 @@ class Manager:
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
if tile['entity'].startswith('cover.'):
return tuple(cover_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
if tile['entity'].startswith('light.'):
return light_effects.related(tile['entity'], self.device_entries(tile['entity']), self.ha.states)
return ()
def device_name_of(self, entity):
"""The name of the entity's device as Home Assistant shows it (the user's name first), or None."""
device = self.registry_index().get(entity, {}).get('device_id')
for item in getattr(self.ha, 'devices', None) or ():
if isinstance(item, dict) and item.get('id') == device:
return item.get('name_by_user') or item.get('name')
return None
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None),
getattr(self.ha, 'state_words', None))
@@ -1088,9 +1140,13 @@ class Manager:
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) else None
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.', 'light.')) else None
entry=self.registry_index().get(tile['entity'])
extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device)
# A light's effects page (app 0.2.83) names the device's selects and numbers as Home Assistant does, with its icons.
light=tile['entity'].startswith('light.')
extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device,
entries=self.registry_index() if light else None,words=getattr(self.ha,'state_words',None) if light else None,
icon_of=row_icon if light else None,device_name=self.device_name_of(tile['entity']) if light else None)
if tile['entity'].startswith('vacuum.'):
ha_catalogue.chip_words(extra,tile['entity'],self.ha.states,device,getattr(self.ha,'state_words',None))
message=state_message(index,tile,self.ha.states,extra,
@@ -1843,7 +1899,7 @@ async def main():
LOG.error('Camera images are off: port %d is not available (%s)', camera_feed.port(), error)
try:
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop())
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop(), manager.card_options_loop())
finally:
await cameras.cleanup()
await runner.cleanup()
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-Cp13kZoB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKR1N3aP.css">
<script type="module" crossorigin src="./assets/index-CteHDGjl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C6Sfd-UM.css">
</head>
<body>
<div id="app"></div>
+7
View File
@@ -338,6 +338,13 @@ HA_DEFAULTS = (
('speaker-pause', 'F1B73'),
('speaker-play', 'F1B72'),
('speedometer', 'F04C5'),
# A light's effects page (firmware 0.2.70+): Home Assistant's icons for an effect and WLED's palette, preset,
# playlist and intensity; the speedometer above serves its speed.
('creation', 'F0674'),
('palette-outline', 'F0E0C'),
('playlist-play', 'F0411'),
('play-speed', 'F08FF'),
('brightness-6', 'F00DF'),
('spoon-sugar', 'F1429'),
('sprout-outline', 'F0E67'),
('square', 'F0764'),
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.82"
version: "0.2.83"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+73
View File
@@ -0,0 +1,73 @@
// c++ -std=c++17 -Wall -Wextra -pedantic tests/test_effects_page.cpp -o /tmp/test_effects_page && /tmp/test_effects_page
// The model of a light's effects page (firmware 0.2.70+): what it can show, how a number maps to its slider, the texts.
#define EFFECTS_PAGE_TEST
#define THEME_TEST
#include "../components/smart_display/effects_page.h"
#include "../components/smart_display/tile_controls.h"
#include <cassert>
#include <cmath>
using namespace effects_page;
using runtime_tiles::Tile;
int main() {
// A light with the EFFECT feature or with rows from its device has a page; a plain light or a switch has none.
Tile wled; wled.entity = "light.vuelta"; wled.supported = 36;
assert(available(wled));
Tile plain; plain.entity = "light.kitchen"; plain.supported = 32;
assert(!available(plain));
runtime_tiles::OptionRow palette; palette.entity = "select.vuelta_kleurenpalet"; palette.name = "Color palette"; palette.current = "Aurora"; palette.count = 75;
plain.edit_extra().option_rows.push_back(palette);
assert(available(plain));
Tile sw; sw.entity = "switch.x"; sw.supported = 4;
assert(!available(sw));
// A number's slider: its place in the range, and the value a slider position stands for, on the entity's step.
assert(percent_of(128, 0, 255) == 50);
assert(percent_of(0, 0, 255) == 0);
assert(percent_of(255, 0, 255) == 100);
assert(percent_of(NAN, 0, 255) == 0);
assert(percent_of(5, 10, 10) == 0);
assert(value_at(50, 0, 255, 1) == 128.0f || value_at(50, 0, 255, 1) == 127.0f);
assert(value_at(100, 0, 255, 1) == 255.0f);
assert(value_at(0, 0, 255, 1) == 0.0f);
assert(value_at(101, 0, 255, 1) == 255.0f);
assert(value_at(50, 16, 32, 0.5f) == 24.0f);
assert(value_at(33, 0, 10, 5) == 5.0f);
assert(number_text(128) == "128");
assert(number_text(12.5f) == "12.5");
assert(number_text(0) == "0");
assert(percent_text(50) == "50 %");
// The roller's text and the row that is chosen in it.
std::vector<std::string> names = {"Solid", "Akemi", "TV Simulator"};
assert(joined(names) == "Solid\nAkemi\nTV Simulator");
assert(joined({}) == "");
assert(index_of(names, "TV Simulator") == 2);
assert(index_of(names, "Blink") == -1);
// A row shows a dash where Home Assistant reports nothing.
assert(row_text("Aurora") == "Aurora");
assert(row_text("") == "—");
assert(row_text("unknown") == "—");
assert(row_text("unavailable") == "—");
// The tile names the effect while one runs; WLED's Solid, Hue's off and Home Assistant's None are none.
assert(tile_controls::effect_running("TV Simulator"));
assert(tile_controls::effect_running("candle"));
assert(!tile_controls::effect_running("Solid"));
assert(!tile_controls::effect_running("solid "));
assert(!tile_controls::effect_running("off"));
assert(!tile_controls::effect_running("Off"));
assert(!tile_controls::effect_running("None"));
assert(!tile_controls::effect_running(""));
assert(!tile_controls::effect_running("unknown"));
// Both boards' sizes: the CYD's page fits four rows and two sliders in 240 px, the Guition's in 480.
Metrics big = metrics(480, 480), small = metrics(320, 240);
assert(big.rows_y + 4 * big.row_h + big.gap + big.number_h <= 480);
assert(small.rows_y + 4 * small.row_h + small.gap + small.number_h <= 240);
assert(big.rows_y + big.roller_rows * big.roller_row_h + 2 * big.roller_pad <= 480);
assert(small.rows_y + small.roller_rows * small.roller_row_h + 2 * small.roller_pad <= 240);
return 0;
}
+160
View File
@@ -0,0 +1,160 @@
"""A light's effects page (app 0.2.83 / firmware 0.2.70): effects, palettes, presets, speed and intensity of a WLED.
- The tile's state message carries the light's `effect` and the rows of its device: the selects and numbers Home
Assistant lists for it, named in English as its frontend names them, with its icons, in a fixed preference order.
- A picker asks for its names when it opens (`esphome.screen_options`); the app answers one page per message, under
the screen's 4096-byte limit, the effects alphabetically with "Solid" first, a select's options as Home Assistant
lists them, and only for a light on the screen's layout or a select on such a light's device.
The values below are what Home Assistant 2026.9 reports for a WLED 16.0.1 in a Dutch installation.
"""
import json
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import light_effects # noqa: E402
import tile_icons # noqa: E402
from core import ATTRS, encode, extras, state_message # noqa: E402
LIGHT = 'light.vuelta'
EFFECTS = ['Solid', 'Blink', 'Breathe', 'Wipe', 'Wipe Random', 'Random Colors', 'Sweep', 'Dynamic', 'Colorloop', 'Rainbow',
'TV Simulator', 'Dynamic Smooth', 'PS Volcano', 'Color Clouds', 'Slow Transition', 'Akemi', 'Sparkle+', 'Noise 1']
PALETTES = ['* Color 1', '* Color Gradient', 'Analogous', 'April Night', 'Aurora', 'Default', 'Rainbow']
def wled():
"""(states, device registry entries, registry index) of a WLED as Home Assistant reports it in Dutch."""
states = {
LIGHT: {'state': 'on', 'attributes': {'friendly_name': 'VUELTA', 'supported_color_modes': ['rgb'], 'effect': 'TV Simulator',
'effect_list': EFFECTS, 'brightness': 255, 'supported_features': 36, 'icon': 'mdi:lamp'}},
'select.vuelta_kleurenpalet': {'state': 'Aurora', 'attributes': {'friendly_name': 'VUELTA Kleurenpalet', 'options': PALETTES}},
'select.vuelta_voorinstelling': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Voorinstelling', 'options': ['Lava lamp', 'Moving blue', 'Simple White', 'nice1']}},
'select.vuelta_afspeellijst': {'state': 'unknown', 'attributes': {'friendly_name': 'VUELTA Afspeellijst', 'options': ['test']}},
'select.vuelta_live_override': {'state': '0', 'attributes': {'friendly_name': 'VUELTA Live override', 'options': ['0', '1', '2']}},
'number.vuelta_snelheid': {'state': '128', 'attributes': {'friendly_name': 'VUELTA Snelheid', 'min': 0, 'max': 255, 'step': 1}},
'number.vuelta_intensiteit': {'state': '64', 'attributes': {'friendly_name': 'VUELTA Intensiteit', 'min': 0, 'max': 255, 'step': 1}},
'switch.vuelta_nachtlampje': {'state': 'off', 'attributes': {'friendly_name': 'VUELTA Nachtlampje'}},
'sensor.vuelta_ip': {'state': '192.168.146.106', 'attributes': {}},
}
keys = {LIGHT: ('segment', None), 'select.vuelta_kleurenpalet': ('color_palette', 'config'), 'select.vuelta_voorinstelling': ('preset', None),
'select.vuelta_afspeellijst': ('playlist', None), 'select.vuelta_live_override': ('live_override', 'config'),
'number.vuelta_snelheid': ('speed', 'config'), 'number.vuelta_intensiteit': ('intensity', 'config'),
'switch.vuelta_nachtlampje': ('nightlight', 'config'), 'sensor.vuelta_ip': ('ip', 'diagnostic')}
device = [{'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': key, 'entity_category': category,
'original_name': states[eid]['attributes'].get('friendly_name', '').replace('VUELTA ', '') or None}
for eid, (key, category) in keys.items()]
return states, device, {item['entity_id']: item for item in device}
WORDS = {'component.wled.entity.select.color_palette.name': 'Color palette', 'component.wled.entity.select.preset.name': 'Preset',
'component.wled.entity.select.playlist.name': 'Playlist', 'component.wled.entity.number.speed.name': 'Speed',
'component.wled.entity.number.intensity.name': 'Intensity', 'component.wled.entity.light.segment.name': 'Segment {segment}'}
ICONS = {'entity': {'wled': {'number': {'speed': {'default': 'mdi:speedometer'}},
'select': {'color_palette': {'default': 'mdi:palette-outline'}, 'playlist': {'default': 'mdi:play-speed'},
'preset': {'default': 'mdi:playlist-play'}}}}}
def icon_of(eid, state, attrs, entry):
return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(eid, state, attrs, entry, ICONS)
class RowsOfTheDevice(unittest.TestCase):
def test_the_light_gets_its_effect_and_the_rows_of_its_device(self):
states, device, index = wled()
self.assertIn('effect', ATTRS)
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS, icon_of=icon_of, device_name='VUELTA')
self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling', 'select.vuelta_afspeellijst'])
self.assertEqual([(r['s'], r['c']) for r in extra['rows']], [('Aurora', 7), ('unknown', 4), ('unknown', 1)])
self.assertEqual([r['i'] for r in extra['rows']], ['F0E0C', 'F0411', 'F08FF'])
self.assertEqual(extra['nums'], [{'e': 'number.vuelta_snelheid', 'n': 'Speed', 'lo': 0, 'hi': 255, 'st': 1, 'v': 128, 'i': 'F04C5'},
{'e': 'number.vuelta_intensiteit', 'n': 'Intensity', 'lo': 0, 'hi': 255, 'st': 1, 'v': 64}])
message = state_message(0, {'entity': LIGHT, 'name': ''}, states, extra)
self.assertEqual(message['a']['effect'], 'TV Simulator')
self.assertNotIn('effect_list', message['a'])
self.assertLess(len(encode(message).encode()), 1200)
# The live override select (options 0/1/2), the switches and the diagnostic sensor stay off the page.
self.assertEqual(light_effects.related(LIGHT, device, states), ('select.vuelta_kleurenpalet', 'select.vuelta_voorinstelling',
'select.vuelta_afspeellijst', 'number.vuelta_snelheid', 'number.vuelta_intensiteit'))
def test_names_fall_back_to_home_assistants_own_without_the_device_name(self):
states, device, index = wled()
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words={}, device_name='VUELTA')
self.assertEqual([r['n'] for r in extra['rows']], ['Kleurenpalet', 'Voorinstelling', 'Afspeellijst'])
self.assertEqual([r['n'] for r in extra['nums']], ['Snelheid', 'Intensiteit'])
self.assertNotIn('i', extra['rows'][0])
# A name with a placeholder is no name ("Segment {segment}").
self.assertEqual(light_effects.entity_name(LIGHT, index[LIGHT], states, WORDS), 'VUELTA')
def test_hidden_disabled_and_empty_entities_are_left_out(self):
states, device, index = wled()
index['select.vuelta_kleurenpalet']['hidden_by'] = 'user'
index['number.vuelta_snelheid']['disabled_by'] = 'integration'
states['select.vuelta_afspeellijst']['attributes']['options'] = []
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
self.assertEqual([r['e'] for r in extra['rows']], ['select.vuelta_voorinstelling'])
self.assertEqual([r['e'] for r in extra['nums']], ['number.vuelta_intensiteit'])
# A light without such a device has no rows and no extra block.
self.assertIsNone(extras({'entity': 'light.kitchen'}, {'light.kitchen': {'state': 'on', 'attributes': {}}}, device=[]))
def test_at_most_three_selects_and_two_numbers_in_preference_order(self):
states, device, index = wled()
for n in range(4):
eid = f'select.vuelta_extra_{n}'
states[eid] = {'state': 'a', 'attributes': {'friendly_name': f'VUELTA Zzz {n}', 'options': ['a', 'b']}}
device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'extra_{n}', 'original_name': f'Zzz {n}'})
eid = f'number.vuelta_more_{n}'
states[eid] = {'state': '1', 'attributes': {'friendly_name': f'VUELTA More {n}', 'min': 0, 'max': 10}}
device.append({'entity_id': eid, 'platform': 'wled', 'device_id': 'wled1', 'translation_key': f'more_{n}', 'original_name': f'More {n}'})
extra = extras({'entity': LIGHT}, states, device=device, entries=index, words=WORDS)
self.assertEqual([r['n'] for r in extra['rows']], ['Color palette', 'Preset', 'Playlist'])
self.assertEqual([r['n'] for r in extra['nums']], ['Speed', 'Intensity'])
class ThePickersNames(unittest.TestCase):
def test_effects_are_alphabetical_with_solid_first(self):
states, _, _ = wled()
names = light_effects.effect_options(states[LIGHT]['attributes'])
self.assertEqual(names[:4], ['Solid', 'Akemi', 'Blink', 'Breathe'])
self.assertEqual(names[-3:], ['TV Simulator', 'Wipe', 'Wipe Random'])
self.assertEqual(len(names), len(EFFECTS))
self.assertEqual(light_effects.effect_options({'effect_list': ['candle', 'off']}), ['candle', 'off'])
self.assertEqual(light_effects.effect_options({}), [])
def test_only_a_light_on_the_layout_or_a_select_of_its_device_is_answered(self):
states, device, _ = wled()
device_of = lambda light: device if light == LIGHT else [] # noqa: E731
self.assertEqual(light_effects.options_for(LIGHT, states, [LIGHT], device_of)[0], 'Solid')
self.assertIsNone(light_effects.options_for(LIGHT, states, ['light.other'], device_of))
self.assertEqual(light_effects.options_for('select.vuelta_kleurenpalet', states, [LIGHT], device_of), PALETTES)
self.assertIsNone(light_effects.options_for('select.vuelta_live_override', states, [LIGHT], device_of))
self.assertIsNone(light_effects.options_for('select.vuelta_kleurenpalet', states, ['light.other'], device_of))
self.assertIsNone(light_effects.options_for('switch.vuelta_nachtlampje', states, [LIGHT], device_of))
# A light without the EFFECT feature answers nothing, whatever its attributes say.
states[LIGHT]['attributes']['supported_features'] = 32
self.assertIsNone(light_effects.options_for(LIGHT, states, [LIGHT], device_of))
def test_pages_stay_under_the_message_limit(self):
names = [f'Effect number {n} with a longer name' for n in range(400)]
pages = light_effects.pages(names)
self.assertGreater(len(pages), 1)
self.assertEqual([n for page in pages for n in page], names)
for i, page in enumerate(pages):
self.assertLess(len(encode(light_effects.message(LIGHT, i, pages)).encode()), 4096)
message = light_effects.message(LIGHT, 99, pages)
self.assertEqual((message['op'], message['i'], message['n']), ('options', len(pages) - 1, len(pages)))
# WLED's 216 effects fit in one page.
wled_names = [f'Effect {n}' for n in range(216)]
self.assertEqual(len(light_effects.pages(wled_names)), 1)
self.assertEqual(light_effects.pages([]), [[]])
self.assertEqual(light_effects.message(LIGHT, 0, light_effects.pages([]))['o'], [])
def test_the_answer_is_what_the_firmware_parses(self):
message = light_effects.message('select.x', 0, light_effects.pages(['A', 'B']))
self.assertEqual(json.loads(encode(message)), {'v': 1, 'op': 'options', 'e': 'select.x', 'i': 0, 'n': 1, 'o': ['A', 'B']})
if __name__ == '__main__':
unittest.main()
Binary file not shown.
Binary file not shown.
Binary file not shown.