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:
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ¤t) {
|
||||
return current.empty() || current == "unknown" || current == "unavailable" || current == "None" ? "—" : current;
|
||||
}
|
||||
|
||||
// Sizes of both boards, from the display's width like light_controls.
|
||||
struct Metrics {
|
||||
int width = 480, height = 480, pad = 20, bar_y = 16, bar = 60, title_y = 35, rows_y = 100, row_h = 56, inset = 18,
|
||||
icon = 26, gap = 12, number_h = 92, track_h = 28, number_inset = 16, roller_rows = 5, roller_row_h = 56, roller_pad = 10,
|
||||
radius = 18, knob = 4;
|
||||
};
|
||||
inline Metrics metrics(int width, int height) {
|
||||
Metrics m;
|
||||
m.width = width; m.height = height;
|
||||
if (width < 480) {
|
||||
m.pad = 12; m.bar_y = 8; m.bar = 40; m.title_y = 21; m.rows_y = 52; m.row_h = 34; m.inset = 10; m.icon = 18; m.gap = 6;
|
||||
m.number_h = 44; m.track_h = 14; m.number_inset = 9; m.roller_row_h = 30; m.roller_pad = 5; m.radius = 10; m.knob = 3;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
} // namespace effects_page
|
||||
|
||||
#ifndef EFFECTS_PAGE_TEST
|
||||
#include "esphome/core/log.h"
|
||||
#include "lvgl.h"
|
||||
#include "theme.h"
|
||||
#include "tile_icon.h"
|
||||
namespace effects_page {
|
||||
// ---- what the board profile wires up at boot ----
|
||||
inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *roller_font = nullptr, *icon_font = nullptr;
|
||||
inline std::function<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
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user