diff --git a/README.md b/README.md index ad15623..4e93065 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,17 @@ also be changed on the screen or by an automation, and your own YAML for one scr 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 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

+

+ 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 + 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 + The same effects page on the CYD: four rows and the two sliders in 320 by 240 pixels +

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 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 The media card in dark mode: the same cover and keys on a black page

-

Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.

+

Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.

CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value Second CYD page: Sonos volume, presence, a scene and an energy graph diff --git a/README_EXTENDED.md b/README_EXTENDED.md index 03b607a..9eabe99 100644 --- a/README_EXTENDED.md +++ b/README_EXTENDED.md @@ -32,6 +32,14 @@ on the screen itself, and how updates work. dial; double-width shows the digital time with the date beside it. - **Light control:** brightness, rainbow color, and white temperature according to the light's capabilities. Open the detailed control with a long touch. +- **Effects (firmware 0.2.70+):** a light that offers effects (a WLED, a Hue with + its candle effect) gets a sparkles key at the top right of its colour card. It + opens a page with a row per thing the lamp offers, named and ordered as Home + Assistant lists them: the light's effect and the select entities of its device + (a WLED's colour palette, preset and playlist), plus a slider per number entity + (speed, intensity). A row opens a drum picker with every name Home Assistant has + at that moment; the check at the top right sends the choice. While an effect + runs, the tile names it instead of the brightness. - **More cards:** climate, vacuum, fan, cover, media player, sensors, select/input_select, number/input_number, switches, scenes, scripts, and buttons. diff --git a/components/smart_display/effects_page.h b/components/smart_display/effects_page.h new file mode 100644 index 0000000..66d7dcc --- /dev/null +++ b/components/smart_display/effects_page.h @@ -0,0 +1,473 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "runtime_model.h" + +// A light's effects page (firmware 0.2.70+, app 0.2.83+): the modes of a lamp such as a WLED, reached from the +// sparkles key at the top right of the light's colour card. One card of rows, each the name of something the lamp +// offers and what it is set to (the light's effect, and the select entities of its device: colour palette, preset, +// playlist), and under it a slider card per number entity of the device (speed, intensity). A row opens a picker: +// LVGL's roller with every name Home Assistant lists at that moment (asked for when the picker opens, one page of +// names per message), and the check key at the top right sends the name the drum stands on. Nothing here knows a brand: the rows, +// their names and their icons come from Home Assistant through the add-on. +// +// Like the settings page, the page is built when it opens and thrown away when it closes, so it costs nothing while +// closed. The model part (percentages, texts, what the page can show) stays free of LVGL for tests/test_effects_page.cpp. +namespace effects_page { +using runtime_tiles::NumberRow; +using runtime_tiles::OptionRow; +using runtime_tiles::Tile; + +// LightEntityFeature.EFFECT: the light offers an effect list. +constexpr uint32_t EFFECT_FEATURE = 4; +// Names a picker holds at most; a WLED lists about 220 effects. +constexpr size_t MAX_NAMES = 400; +// A chosen name stays on its row while Home Assistant still reports the old one, this long at most. +constexpr uint32_t SENT_HOLD_MS = 4000; + +inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); } +// Whether a light has an effects page: it offers effects, or its device has rows to show. +inline bool available(const Tile &t) { + return t.domain() == "light" && ((t.supported & EFFECT_FEATURE) != 0 || !t.extra().option_rows.empty() || !t.extra().number_rows.empty()); +} +// A number's place on its slider, 0-100 % of its range. +inline int percent_of(float value, float low, float high) { + if (!std::isfinite(value) || !(high > low)) return 0; + return clamp(static_cast(std::lround((value - low) * 100 / (high - low))), 0, 100); +} +// The number a slider stands for, on the entity's own step and inside its range. +inline float value_at(int percent, float low, float high, float step) { + float value = low + (high - low) * clamp(percent, 0, 100) / 100.0f; + if (step > 0) value = low + std::round((value - low) / step) * step; + return std::min(high, std::max(low, value)); +} +// A number as Home Assistant's action takes it: "128", "12.5", never "128.000000". +inline std::string number_text(float value) { + char b[24]; + snprintf(b, sizeof(b), "%.3f", value); + std::string text = b; + while (text.size() > 1 && text.back() == '0') text.pop_back(); + if (text.back() == '.') text.pop_back(); + return text; +} +inline std::string percent_text(int percent) { return std::to_string(percent) + " %"; } +// The roller takes its names as one string, a line per name. +inline std::string joined(const std::vector &names) { + std::string out; + for (size_t i = 0; i < names.size(); ++i) { if (i) out += '\n'; out += names[i]; } + return out; +} +inline int index_of(const std::vector &names, const std::string &name) { + for (size_t i = 0; i < names.size(); ++i) if (names[i] == name) return static_cast(i); + return -1; +} +// The value a row shows: the name Home Assistant reports, a dash while it reports none. +inline std::string row_text(const std::string ¤t) { + return current.empty() || current == "unknown" || current == "unavailable" || current == "None" ? "—" : current; +} + +// Sizes of both boards, from the display's width like light_controls. +struct Metrics { + int width = 480, height = 480, pad = 20, bar_y = 16, bar = 60, title_y = 35, rows_y = 100, row_h = 56, inset = 18, + icon = 26, gap = 12, number_h = 92, track_h = 28, number_inset = 16, roller_rows = 5, roller_row_h = 56, roller_pad = 10, + radius = 18, knob = 4; +}; +inline Metrics metrics(int width, int height) { + Metrics m; + m.width = width; m.height = height; + if (width < 480) { + m.pad = 12; m.bar_y = 8; m.bar = 40; m.title_y = 21; m.rows_y = 52; m.row_h = 34; m.inset = 10; m.icon = 18; m.gap = 6; + m.number_h = 44; m.track_h = 14; m.number_inset = 9; m.roller_row_h = 30; m.roller_pad = 5; m.radius = 10; m.knob = 3; + } + return m; +} +} // namespace effects_page + +#ifndef EFFECTS_PAGE_TEST +#include "esphome/core/log.h" +#include "lvgl.h" +#include "theme.h" +#include "tile_icon.h" +namespace effects_page { +// ---- what the board profile wires up at boot ---- +inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *roller_font = nullptr, *icon_font = nullptr; +inline std::function tile_of; // a tile by entity +inline std::function send; // service, entity, key, value +inline std::function ask; // options_request +inline std::function drift; // finger travel this touch +inline int tap_limit = 0; +inline uint32_t (*now)() = nullptr; + +// ---- state while open ---- +inline lv_obj_t *root = nullptr, *picker = nullptr; +inline std::string entity; // the light +struct RowDrawn { lv_obj_t *card = nullptr, *value = nullptr; std::string entity, sent; uint32_t sent_at = 0; bool light = false; std::string name; }; +struct NumberDrawn { lv_obj_t *slider = nullptr, *value = nullptr; std::string entity; float low = 0, high = 100, step = 1; bool dirty = false; }; +inline std::vector rows; +inline std::vector numbers; +// The picker's names: one entity at a time, page by page from the add-on. +struct Options { std::string entity; std::vector names; unsigned next = 0, pages = 0; bool complete = false; uint32_t asked_at = 0; }; +inline Options options; +inline lv_obj_t *roller = nullptr, *roller_note = nullptr; +inline int picker_row = -1; + +inline uint32_t clock() { return now ? now() : lv_tick_get(); } +// The page's sizes from the screen (a page just made has no width of its own until LVGL lays it out). +inline Metrics screen_metrics() { + auto *display = lv_display_get_default(); + return metrics(lv_display_get_horizontal_resolution(display), lv_display_get_vertical_resolution(display)); +} +inline bool visible() { return root != nullptr; } +inline bool steady() { return !drift || tap_limit <= 0 || drift() <= tap_limit; } +inline const Tile *tile() { return tile_of ? tile_of(entity) : nullptr; } +// Whether the colour card of this light shows the sparkles key. +inline bool offered(const std::string &light) { const Tile *t = tile_of ? tile_of(light) : nullptr; return t && available(*t); } + +inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) { + auto *obj = lv_obj_create(parent); + lv_obj_remove_style_all(obj); + lv_obj_remove_flag(obj, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); + lv_obj_set_pos(obj, x, y); + lv_obj_set_size(obj, w, h); + return obj; +} +inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, theme::Role color, + lv_text_align_t align = LV_TEXT_ALIGN_LEFT) { + auto *label = lv_label_create(parent); + lv_label_set_text(label, value.c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_color(label, theme::color(color), 0); + lv_obj_set_style_text_align(label, align, 0); + lv_obj_set_height(label, lv_font_get_line_height(font)); + return label; +} +inline lv_obj_t *card(lv_obj_t *parent, int x, int y, int w, int h, int radius) { + auto *obj = plain(parent, x, y, w, h); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(obj, theme::color(theme::CARD), 0); + lv_obj_set_style_border_width(obj, 1, 0); + lv_obj_set_style_border_color(obj, theme::color(theme::LINE), 0); + lv_obj_set_style_radius(obj, radius, 0); + return obj; +} +// The glyph a row shows: Home Assistant's icon when these fonts carry it, else one of the page's own. +inline std::string glyph(uint32_t icon, const char *fallback) { + if (icon && icon_font) { lv_font_glyph_dsc_t dsc; if (lv_font_get_glyph_dsc(icon_font, &dsc, icon, 0)) return tile_icon::utf8(icon); } + return fallback; +} +// A round key like the back key of every card, with a glyph in the middle. +inline lv_obj_t *round_key(lv_obj_t *parent, int x, int y, int size, const char *glyph, lv_event_cb_t handler) { + auto *key = plain(parent, x, y, size, size); + lv_obj_add_flag(key, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(key, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(key, theme::color(theme::KEY), 0); + lv_obj_set_style_bg_color(key, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED); + lv_obj_set_style_radius(key, LV_RADIUS_CIRCLE, 0); + auto *label = text(key, glyph, icon_font ? icon_font : row_font, theme::INK); + lv_obj_set_width(label, LV_SIZE_CONTENT); + lv_obj_center(label); + lv_obj_add_event_cb(key, handler, LV_EVENT_SHORT_CLICKED, nullptr); + return key; +} +// The same top bar as every card: a round back key and the name in the middle, and on the picker the check key at +// the top right, where the colour card has its sparkles key. +inline lv_obj_t *top_bar(lv_obj_t *parent, const Metrics &m, const std::string &title, lv_event_cb_t back, + const char *right_glyph = nullptr, lv_event_cb_t right = nullptr) { + auto *key = round_key(parent, m.pad, m.bar_y, m.bar, "\U000F004D", back); + if (right_glyph && right) round_key(parent, m.width - m.pad - m.bar, m.bar_y, m.bar, right_glyph, right); + const lv_font_t *heading = title_font ? title_font : row_font; + auto *label = text(parent, title, heading, theme::INK, LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(label, m.width - 2 * (m.pad + m.bar + 8)); + lv_obj_set_pos(label, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2); + return key; +} +inline lv_obj_t *page_root() { + auto *obj = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(obj); + lv_obj_set_size(obj, lv_pct(100), lv_pct(100)); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to what lies under it + lv_obj_set_style_bg_color(obj, theme::color(theme::PAGE_SOFT), 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + lv_obj_move_foreground(obj); + return obj; +} + +// ---- the picker ---- +inline void close_picker() { + if (picker) { lv_obj_delete(picker); picker = nullptr; } + roller = roller_note = nullptr; + picker_row = -1; +} +inline void choose(const std::string &name) { + if (picker_row < 0 || picker_row >= static_cast(rows.size()) || name.empty()) return; + auto &row = rows[picker_row]; + // What Home Assistant reports for the row now: the drum resting on it again sends nothing. + std::string current; + if (const Tile *t = tile()) { + if (row.light) current = t->extra().effect; + else for (auto &r : t->extra().option_rows) if (r.entity == row.entity) current = r.current; + } + if (name == row.sent || (row.sent.empty() && name == current)) return; + row.sent = name; row.sent_at = clock(); + if (row.value) lv_label_set_text(row.value, name.c_str()); + ESP_LOGI("effects", "%s: %s", row.entity.c_str(), name.c_str()); + if (!send) return; + if (row.light) send("light.turn_on", row.entity, "effect", name); + else send("select.select_option", row.entity, "option", name); +} +// The check key: the name the drum stands on goes out, and the picker closes. Nothing is sent before it. +inline void confirm() { + if (!roller) return; + char name[64]; + lv_roller_get_selected_str(roller, name, sizeof(name)); + choose(name); + close_picker(); +} +inline void confirm_event(lv_event_t *) { if (steady()) confirm(); } +inline void show_roller() { + if (!picker || !roller_note || options.names.empty()) return; + auto *holder = lv_obj_get_parent(roller_note); + const Metrics m = screen_metrics(); + lv_obj_add_flag(roller_note, LV_OBJ_FLAG_HIDDEN); + const lv_font_t *font = roller_font ? roller_font : row_font; + roller = lv_roller_create(holder); + lv_obj_remove_style_all(roller); + lv_obj_set_style_bg_opa(roller, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_text_font(roller, font, LV_PART_MAIN); + lv_obj_set_style_text_color(roller, theme::color(theme::MUTED), LV_PART_MAIN); + lv_obj_set_style_text_align(roller, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_line_space(roller, std::max(0, m.roller_row_h - (int) lv_font_get_line_height(font)), LV_PART_MAIN); + lv_obj_set_style_anim_duration(roller, 200, LV_PART_MAIN); + lv_obj_set_style_bg_opa(roller, LV_OPA_COVER, LV_PART_SELECTED); + lv_obj_set_style_bg_color(roller, theme::color(theme::ACCENT_TINT), LV_PART_SELECTED); + lv_obj_set_style_radius(roller, m.roller_row_h / 2, LV_PART_SELECTED); + lv_obj_set_style_text_color(roller, theme::color(theme::INK), LV_PART_SELECTED); + lv_obj_set_style_text_font(roller, font, LV_PART_SELECTED); + lv_roller_set_options(roller, joined(options.names).c_str(), LV_ROLLER_MODE_NORMAL); + lv_roller_set_visible_row_count(roller, m.roller_rows); + // The card's width from the sizes, not from the card: a card made a moment ago has no width until LVGL lays it out. + lv_obj_set_width(roller, m.width - 2 * m.pad - 2 * m.inset); + lv_obj_center(roller); + const auto &row = rows[picker_row]; + std::string current = !row.sent.empty() ? row.sent : row.light ? (tile() ? tile()->extra().effect : std::string()) : [&]() { + if (auto *t = tile()) for (auto &r : t->extra().option_rows) if (r.entity == row.entity) return r.current; + return std::string(); + }(); + int index = index_of(options.names, current); + if (index >= 0) lv_roller_set_selected(roller, static_cast(index), LV_ANIM_OFF); +} +// One page of names from the add-on (runtime_tiles::options_received); the roller appears with the last one. +inline void received(const std::string &for_entity, unsigned page, unsigned pages, std::vector &&names) { + if (!picker || for_entity != options.entity || options.complete) return; + if (page != options.next) return; // an answer to an older question, or one that arrived twice + for (auto &name : names) { if (options.names.size() >= MAX_NAMES) break; options.names.push_back(std::move(name)); } + options.next = page + 1; + options.pages = std::max(1u, pages); + if (options.next < options.pages && options.names.size() < MAX_NAMES) { options.asked_at = clock(); if (ask) ask(options.entity, options.next); return; } + options.complete = true; + if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, "Nothing to choose"); return; } + show_roller(); +} +inline void picker_back(lv_event_t *) { if (steady()) close_picker(); } +inline void open_picker(int index) { + if (!root || index < 0 || index >= static_cast(rows.size())) return; + close_picker(); + picker_row = index; + const auto &row = rows[index]; + const Metrics m = screen_metrics(); + picker = page_root(); + top_bar(picker, m, row.name, picker_back, "\U000F012C", confirm_event); + const int w = m.width - 2 * m.pad, h = m.roller_rows * m.roller_row_h + 2 * m.roller_pad; + auto *holder = card(picker, m.pad, m.rows_y, w, h, m.radius); + roller_note = text(holder, "Loading...", row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER); + lv_obj_set_width(roller_note, w - 2 * m.inset); + lv_obj_center(roller_note); + // The names of the same entity from a moment ago serve again; anything else is asked for afresh. + if (options.entity == row.entity && options.complete && !options.names.empty()) { show_roller(); return; } + options = Options{}; + options.entity = row.entity; + options.asked_at = clock(); + if (ask) ask(row.entity, 0); + else lv_label_set_text(roller_note, "Not connected"); +} + +// ---- the page ---- +inline void row_event(lv_event_t *e) { + if (lv_event_get_code(e) != LV_EVENT_SHORT_CLICKED || !steady()) return; + open_picker(static_cast(reinterpret_cast(lv_event_get_user_data(e)))); +} +inline void preview(NumberDrawn &n) { + if (n.value) lv_label_set_text(n.value, percent_text(lv_slider_get_value(n.slider)).c_str()); +} +inline void slider_event(lv_event_t *e) { + auto &n = numbers[reinterpret_cast(lv_event_get_user_data(e))]; + const auto code = lv_event_get_code(e); + if (code == LV_EVENT_VALUE_CHANGED) { n.dirty = true; preview(n); } + if (code == LV_EVENT_PRESS_LOST) n.dirty = false; + if (code == LV_EVENT_RELEASED && n.dirty) { + n.dirty = false; + const float value = value_at(lv_slider_get_value(n.slider), n.low, n.high, n.step); + ESP_LOGI("effects", "%s: %s", n.entity.c_str(), number_text(value).c_str()); + if (send) send("number.set_value", n.entity, "value", number_text(value)); + } +} +inline void back_event(lv_event_t *) { if (steady()) { close_picker(); if (root) { lv_obj_delete(root); root = nullptr; } rows.clear(); numbers.clear(); } } +inline void draw() { + if (!root) return; + lv_obj_clean(root); + rows.clear(); numbers.clear(); + const Tile *t = tile(); + const Metrics m = screen_metrics(); + top_bar(root, m, t ? (t->name.empty() ? t->entity : t->name) : entity, back_event); + const int w = m.width - 2 * m.pad; + // The rows: the light's effect first, then the selects of its device. + struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; }; + std::vector specs; + if (t && (t->supported & EFFECT_FEATURE)) specs.push_back({t->entity, "Effect", t->extra().effect, "\U000F0674", 0, true}); + if (t) for (auto &r : t->extra().option_rows) specs.push_back({r.entity, r.name, r.current, "\U000F0411", r.icon, false}); + int y = m.rows_y; + if (!specs.empty()) { + auto *holder = card(root, m.pad, y, w, static_cast(specs.size()) * m.row_h, m.radius); + for (size_t i = 0; i < specs.size(); ++i) { + auto &spec = specs[i]; + auto *row = plain(holder, 0, static_cast(i) * m.row_h, w, m.row_h); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_set_style_bg_color(row, theme::color(theme::CARD_PRESSED), LV_STATE_PRESSED); + lv_obj_set_style_radius(row, m.radius, 0); + lv_obj_add_event_cb(row, row_event, LV_EVENT_SHORT_CLICKED, reinterpret_cast(static_cast(i))); + const int icon_h = icon_font ? lv_font_get_line_height(icon_font) : 0, text_h = lv_font_get_line_height(row_font); + int x = m.inset; + if (icon_font) { + auto *icon = text(row, glyph(spec.icon, spec.fallback.c_str()), icon_font, theme::MUTED); + lv_obj_set_width(icon, LV_SIZE_CONTENT); + lv_obj_set_pos(icon, m.inset - 2, (m.row_h - icon_h) / 2); + x += m.icon + 6; + } + auto *name = text(row, spec.name, row_font, theme::INK); + lv_obj_set_pos(name, x, (m.row_h - text_h) / 2); + const int chevron_w = icon_font ? m.icon : 0; + lv_obj_set_width(name, std::max(20, w / 2 - x)); + if (icon_font) { + auto *chevron = text(row, "\U000F0142", icon_font, theme::CHEVRON); + lv_obj_set_width(chevron, LV_SIZE_CONTENT); + lv_obj_set_pos(chevron, w - m.inset - chevron_w + 2, (m.row_h - icon_h) / 2); + } + auto *value = text(row, row_text(spec.current), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT); + const int value_x = x + w / 2 - x + 4; + lv_obj_set_pos(value, value_x, (m.row_h - text_h) / 2); + lv_obj_set_width(value, std::max(20, w - m.inset - chevron_w - 4 - value_x)); + RowDrawn drawn; drawn.card = row; drawn.value = value; drawn.entity = spec.entity; drawn.light = spec.light; drawn.name = spec.name; + rows.push_back(std::move(drawn)); + } + y += static_cast(specs.size()) * m.row_h + m.gap; + } + // The numbers: half-width slider cards side by side, like the brightness slider of the colour card. + if (t && !t->extra().number_rows.empty()) { + const size_t count = std::min(2, t->extra().number_rows.size()); + const int card_w = count == 1 ? w : (w - m.gap) / 2; + for (size_t i = 0; i < count; ++i) { + const auto &n = t->extra().number_rows[i]; + const int x = m.pad + static_cast(i) * (card_w + m.gap); + auto *holder = card(root, x, y, card_w, m.number_h, m.radius); + const int text_h = lv_font_get_line_height(row_font), text_y = m.number_inset * 3 / 4; + int tx = m.number_inset; + if (icon_font) { + auto *icon = text(holder, glyph(n.icon, "\U000F00DF"), icon_font, theme::MUTED); + lv_obj_set_width(icon, LV_SIZE_CONTENT); + const int icon_h = lv_font_get_line_height(icon_font); + lv_obj_set_pos(icon, m.number_inset - 2, text_y + (text_h - icon_h) / 2); + tx += m.icon - 2 + 5; + } + auto *name = text(holder, n.name, row_font, theme::INK); + lv_obj_set_pos(name, tx, text_y); + lv_obj_set_width(name, std::max(20, card_w - tx - m.number_inset - 52)); + auto *value = text(holder, percent_text(percent_of(n.value, n.low, n.high)), row_font, theme::MUTED, LV_TEXT_ALIGN_RIGHT); + lv_obj_set_width(value, 50); + lv_obj_set_pos(value, card_w - m.number_inset - 50, text_y); + // Corners, handle and shortest fill as the colour card's brightness slider: the fill keeps the track's radius + // (a smaller one makes LVGL draw it into a buffer of its own on every redraw). + const int track_x = m.number_inset, track_w = card_w - 2 * m.number_inset, track_h = m.track_h, track_y = m.number_h - m.number_inset - track_h + 2; + auto *slider = lv_slider_create(holder); + lv_obj_remove_style_all(slider); + lv_obj_remove_flag(slider, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_pos(slider, track_x, track_y); lv_obj_set_size(slider, track_w, track_h); + lv_obj_set_ext_click_area(slider, m.width >= 480 ? 12 : 8); + const int corner = track_h * 12 / 42; + lv_obj_set_style_radius(slider, corner, LV_PART_MAIN); + lv_obj_set_style_radius(slider, corner, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_TINT), LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(slider, theme::color(theme::ACCENT_BRIGHT), LV_PART_INDICATOR); + const int handle = m.knob, back = std::max(1, track_h / 8) + handle / 2, half = track_h >> 1; + lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_KNOB); + lv_obj_set_style_bg_color(slider, theme::color(theme::KNOB), LV_PART_KNOB); + lv_obj_set_style_radius(slider, 2, LV_PART_KNOB); + lv_obj_set_style_pad_left(slider, back + handle / 2 - half, LV_PART_KNOB); + lv_obj_set_style_pad_right(slider, handle / 2 - back - (track_h - half), LV_PART_KNOB); + lv_obj_set_style_pad_top(slider, -(track_h / 4), LV_PART_KNOB); + lv_obj_set_style_pad_bottom(slider, -(track_h / 4), LV_PART_KNOB); + lv_slider_set_range(slider, 0, 100); + lv_slider_set_value(slider, percent_of(n.value, n.low, n.high), LV_ANIM_OFF); + NumberDrawn drawn; drawn.slider = slider; drawn.value = value; drawn.entity = n.entity; drawn.low = n.low; drawn.high = n.high; drawn.step = n.step; + numbers.push_back(std::move(drawn)); + lv_obj_add_event_cb(slider, slider_event, LV_EVENT_ALL, reinterpret_cast(static_cast(i))); + } + } +} +// A state of the light while its page is open: rows follow Home Assistant, a row just chosen keeps its choice a +// moment, a slider under a finger is left alone. +inline void updated(const Tile &t) { + if (!root || t.entity != entity) return; + const uint32_t moment = clock(); + for (auto &row : rows) { + std::string current; + if (row.light) current = t.extra().effect; + else for (auto &r : t.extra().option_rows) if (r.entity == row.entity) current = r.current; + if (!row.sent.empty() && current != row.sent && moment - row.sent_at < SENT_HOLD_MS) continue; + if (!row.sent.empty() && (current == row.sent || moment - row.sent_at >= SENT_HOLD_MS)) row.sent.clear(); + if (row.value) lv_label_set_text(row.value, row_text(current).c_str()); + } + for (auto &n : numbers) { + if (!n.slider || lv_obj_has_state(n.slider, LV_STATE_PRESSED) || n.dirty) continue; + for (auto &r : t.extra().number_rows) if (r.entity == n.entity) { + const int percent = percent_of(r.value, r.low, r.high); + if (lv_slider_get_value(n.slider) != percent) { lv_slider_set_value(n.slider, percent, LV_ANIM_OFF); preview(n); } + } + } +} +inline void open(const std::string &light) { + const Tile *t = tile_of ? tile_of(light) : nullptr; + if (!t || !available(*t)) return; + close_picker(); + entity = light; + if (!root) root = page_root(); + lv_obj_move_foreground(root); + draw(); + ESP_LOGI("effects", "Effects page of %s: %u rows, %u sliders", light.c_str(), (unsigned) rows.size(), (unsigned) numbers.size()); +} +inline void close() { + close_picker(); + rows.clear(); numbers.clear(); + options = Options{}; + if (root) { lv_obj_delete(root); root = nullptr; } + entity.clear(); +} +// A change of look while the page is open: drawn again in place, the picker closed. +inline void restyle() { + if (!root) return; + close_picker(); + lv_obj_set_style_bg_color(root, theme::color(theme::PAGE_SOFT), 0); + draw(); +} +} // namespace effects_page +#endif diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 1c46e62..876b6a6 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -92,7 +92,15 @@ struct Choice { // What only some tiles carry: climate modes, a select's options, weather, sun and timer times, a media // title, the vacuum rows. A light or a sensor has none of it, so a tile holds this block only while its // state needs one: twenty tiles with these fields inline took 24 KB of the CYD's RAM, mostly empty. +// A light's effects page (firmware 0.2.70+): a select entity of the light's device with what it is set to and how many +// options it has, and a number entity with its range; the names and icons are Home Assistant's, through the add-on. +struct OptionRow { std::string entity, name, current; uint16_t count = 0; uint32_t icon = 0; }; +struct NumberRow { std::string entity, name; float value = NAN, low = 0, high = 100, step = 1; uint32_t icon = 0; }; struct Extra { + // The effect a light runs (its `effect` attribute), and the rows of its effects page. + std::string effect; + std::vector option_rows; + std::vector number_rows; // Climate: the modes as JSON lists, the current fan and swing mode, and what it is doing now. std::string hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode, hvac_action; // A select's options, at most eight. @@ -133,7 +141,7 @@ struct Extra { remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() && media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() && choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() && - action_templates.empty() && state_word.empty(); + action_templates.empty() && state_word.empty() && effect.empty() && option_rows.empty() && number_rows.empty(); } }; // The Extra of a tile on the heap, copied along with the tile like an ordinary member. diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index e4e256d..22c6c52 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -9,6 +9,7 @@ #include "esphome/core/preferences.h" #include "cyd_ui.h" #include "light_controls.h" +#include "effects_page.h" #include "tile_controls.h" #include "history_view.h" #include "camera_view.h" @@ -194,6 +195,8 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) { return s.valid(); } inline std::function detail, detail_update; +// One page of a picker's names (op "options", app 0.2.83+), for the light's effects page. +inline std::function &&)> options_received; struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{}; @@ -486,6 +489,20 @@ inline std::string receive(const std::string &payload) { result = model.ready() ? "Synced" : "Loading tiles"; return true; } + if (op == "options") { + // The names a picker on a light's effects page asked for (options_request), one page per message. + std::string entity = string(root["e"], 120); + if (!valid_entity(entity) || !root["o"].is()) return false; + std::vector names; + for (JsonVariant name : root["o"].as()) { + if (names.size() == effects_page::MAX_NAMES) break; + std::string text = string(name, 48); + if (!text.empty()) names.push_back(std::move(text)); + } + if (options_received) options_received(entity, root["i"] | 0u, root["n"] | 1u, std::move(names)); + result = model.ready() ? "Synced" : "Loading tiles"; + return true; + } #ifdef SWIPE_PROFILE if (op == "swipe_test") { // Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back` @@ -644,6 +661,25 @@ inline std::string receive(const std::string &payload) { }; if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(next); } for (auto &[kind, value] : tapped) if (auto *c = next.choice(kind)) if (c->current != value) c->sent = value; + // A light's effects page (app 0.2.83+): the effect it runs, and the selects and numbers of its device. + next.effect = string(a["effect"], 48); + if (extra["rows"].is()) for (JsonVariant r : extra["rows"].as()) { + if (next.option_rows.size() == 3) break; + OptionRow row; row.entity = string(r["e"], 120); + if (!valid_entity(row.entity)) continue; + row.name = string(r["n"], 32); row.current = string(r["s"], 48); row.count = static_cast(std::min(r["c"] | 0u, 65535u)); + row.icon = tile_icon::codepoint(string(r["i"], 8)); + next.option_rows.push_back(std::move(row)); + } + if (extra["nums"].is()) for (JsonVariant r : extra["nums"].as()) { + if (next.number_rows.size() == 2) break; + NumberRow row; row.entity = string(r["e"], 120); + if (!valid_entity(row.entity)) continue; + row.name = string(r["n"], 32); row.value = number(r["v"]); row.low = number(r["lo"], 0); row.high = number(r["hi"], 100); row.step = number(r["st"], 1); + if (!(row.high > row.low)) continue; + row.icon = tile_icon::codepoint(string(r["i"], 8)); + next.number_rows.push_back(std::move(row)); + } if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]); next.charging = extra["chg"].is() && extra["chg"].as() == 1; next.room = string(extra["room"], 32); @@ -785,6 +821,25 @@ inline void perform(const Tile &tile) { ESP_LOGI("runtime_action", "Sent service=%s entity=%s (%u values)", x.action.c_str(), tile.entity.c_str(), (unsigned) (x.action_data.size() + x.action_templates.size())); } +// A picker on a light's effects page asks the manager for its names (app 0.2.83+ answers with op "options"), one +// page at a time. An event, like history_request. +inline void options_request(const std::string &entity, unsigned page) { + if (inbox.empty()) return; + esphome::api::HomeassistantActionRequest request; + request.service = esphome::StringRef("esphome.screen_options"); + request.is_event = true; + const std::string number = std::to_string(page); + const std::string keys[] = {"inbox", "entity", "page"}, values[] = {inbox, entity, number}; + request.data.init(3); + for (int i = 0; i < 3; ++i) { + esphome::api::HomeassistantServiceMap entry; + entry.key = esphome::StringRef(keys[i]); + entry.value = esphome::StringRef(values[i]); + request.data.push_back(entry); + } + esphome::api::global_api_server->send_homeassistant_action(request); + ESP_LOGI("effects", "Asked for the names of %s, page %u", entity.c_str(), page); +} // A detail card asks the manager for its history (app 0.2.59+ answers with op "history"). An event, like // setting_event: it needs no permission to call Home Assistant actions. inline void history_request(const std::string &entity, uint32_t hours) { @@ -3066,6 +3121,7 @@ inline void render_slot(size_t slot) { else if (t.is_page()) value = "Page " + std::to_string(t.page_target()); else if (!fresh() || !t.available()) value = "Unavailable"; else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused"; + else if (d == "light" && t.state == "on" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect; else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %"; else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; } else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state; diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h index 83360ff..3ed7ab9 100644 --- a/components/smart_display/tile_controls.h +++ b/components/smart_display/tile_controls.h @@ -350,6 +350,14 @@ inline void settle_suction(runtime_tiles::Extra &x) { } if (row) row->current = x.fan_speed; } +// Whether a light runs an effect worth naming on its tile (firmware 0.2.70+): WLED's "Solid" is its plain colour, a Hue's +// "off" and Home Assistant's "None" mean no effect. +inline bool effect_running(const std::string &effect) { + std::string lower; + for (char c : effect) lower += static_cast(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c); + while (!lower.empty() && lower.back() == ' ') lower.pop_back(); + return !lower.empty() && lower != "solid" && lower != "off" && lower != "none" && lower != "unknown" && lower != "unavailable"; +} // The service call behind a chip: a select option on the device, or the vacuum's own fan speed. inline Action choice_action(const Tile &t, char kind, const std::string &value) { if (kind == 's') return {"vacuum.set_fan_speed", "fan_speed", value}; diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index 4911a4a..be1d22d 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -294,6 +294,11 @@ inline constexpr Named NAMES[] = { {"speaker-pause", 0xF1B73}, {"speaker-play", 0xF1B72}, {"speedometer", 0xF04C5}, + {"creation", 0xF0674}, + {"palette-outline", 0xF0E0C}, + {"playlist-play", 0xF0411}, + {"play-speed", 0xF08FF}, + {"brightness-6", 0xF00DF}, {"spoon-sugar", 0xF1429}, {"sprout-outline", 0xF0E67}, {"square", 0xF0764}, diff --git a/docs/TEST_RESULTS_0283.md b/docs/TEST_RESULTS_0283.md new file mode 100644 index 0000000..e674125 --- /dev/null +++ b/docs/TEST_RESULTS_0283.md @@ -0,0 +1,64 @@ +# Test results app 0.2.83 / firmware 0.2.70 (2026-09-18) + +Max asked how hard it is to choose a WLED lamp's modes on the screen ("daar heb je een mega lijst aan modes"), said the +list is dynamic and differs per lamp, wanted it as LVGL/ESPHome-native as the documentation allows, and then added the +other WLED things: presets "en die andere". From six questions on a design page he chose: the sparkles key at the top +right of the colour card (1A), LVGL's roller as the picker for every row (3B), the rows as drawn without preset chips, +effects alphabetically with Solid on top, English names from Home Assistant's own translations, and the effect's name +on the tile while one runs. Mid-build he asked for an explicit confirmation: the check key at the top right of the +picker, in the same slot; the back key sends nothing. + +## What changed + +- App: `light_effects.py` (the rows of the light's device, the names and icons Home Assistant gives them, the picker's + names and their pages), `ATTRS` carries `effect`, `extras()` builds `x.rows` and `x.nums` for a light, the manager + watches the device's selects and numbers with the light, subscribes to `esphome.screen_options` and answers with + `op: options` (`card_options_loop`), and five firmware-only glyphs join `tile_icons.FIXED` (creation, palette-outline, + playlist-play, play-speed, brightness-6). Nothing about WLED is hardcoded beyond a preferred order of translation keys. +- Firmware: `effects_page.h` (the page, the picker, the sliders; the model part is LVGL-free), `Extra` gained `effect`, + `option_rows` and `number_rows`, the state parser reads them, the tile's value line names a running effect + (`tile_controls::effect_running`), `runtime_tiles::options_request` asks for a picker's names and `op: options` + answers reach `effects_page::received`. Both profiles: a hidden `roller_seed` so ESPHome compiles LVGL's roller, the + `color_effects_button` on the colour card, the page's hooks at boot, and its close on dismiss, redraw and live updates. +- Docs: README pictures (`guition-effects.png`, `guition-effects-picker.png`, `cyd-effects.png`, rendered by the host + program), a bullet in README_EXTENDED, the CHANGELOG. + +## Automated + +- `tools/check.sh`: Python 463 tests (`tests/test_light_effects.py` is new: rows, names, exclusions, caps, sorting, + pages under 4096 bytes, who may ask), C++ 19/19 (`tests/test_effects_page.cpp` is new: what has a page, percent and + value mapping, texts, both boards' sizes; `effect_running`), packages, icons, Vitest, vue-tsc, build and the bundle. +- `tools/check.sh --firmware` (ESPHome 2026.6.2): CYD 1,675,424 B = 91.3 % of the 1,835,008 B slot, +20,288 B against + 0.2.82 (tight band: LVGL's roller is compiled in for the first time, the page's code, and five glyphs in three icon + fonts), Guition well under its 8 MB slot. The first ESP32 compile caught the usual trap the host build cannot: + `std::max(0, int - lv_font_get_line_height())` needs an `(int)` cast (int32_t is long there). + +## On the host (the real firmware for the Mac, SDL, own API ports 6231/6232) + +`effects_host.py` builds each board's package as a host program with probe actions, sends a layout of three lights (a +WLED, a plain light, a Hue with its candle effect) whose messages the real add-on code builds +(`core.extras` + `state_message`), answers the screen's `esphome.screen_options` events with the real +`light_effects.options_for/pages/message`, and checks 35 steps per board, 35/35 on the Guition and 35/35 on the CYD: + +- The WLED tile reads "TV Simulator", the plain light "59 %", the Hue "candle". +- The colour card of the WLED shows the sparkles key; a plain light's card has none; a Hue with effects has one. +- The page shows Effect, Color palette, Preset and Playlist with Home Assistant's values (a dash for unknown), Speed + and Intensity at 50 %, everything inside the screen on both boards. +- A row asks for its names (`esphome.screen_options` with inbox, entity and page 0) and says "Loading..." meanwhile; the + add-on answers one page with Solid first; the roller shows all 216 and rests on the current effect. +- Turning the drum sends nothing; the check key sends one `light.turn_on` with the effect and closes the picker; the row + says the choice at once, a stale state does not flip it back, the reported effect settles it; opening the picker again + uses the names it still holds and confirming the current effect sends nothing. +- A second LVGL pointer drags the drum without sending; the check sends the effect it stands on. +- The palette row asks for the select's options, lists them in Home Assistant's order, rests on Aurora, and a choice + goes out as `select.select_option`; the back key of the picker sends nothing. +- A slider release sends `number.set_value` on the entity's own scale (80 % of 0-255 is 204); the label follows the + finger; a reported value keeps the slider, a value changed elsewhere moves it. +- Dark mode draws the page again; closing the page returns to the colour card; closing the cards closes the page too. +- Renders of every state on both boards, light and dark, are what the README shows. + +## Not tested + +- On real hardware: the roller under a real finger (GT911 and XPT2046), the keys, and the answer path through a live + Home Assistant with a real WLED. Max's Guition Wallbox at home and his VUELTA are the place for that. +- A select with more names than one message holds (the paging is covered by the Python tests only). diff --git a/docs/images/cyd-effects.png b/docs/images/cyd-effects.png new file mode 100644 index 0000000..0fbd613 Binary files /dev/null and b/docs/images/cyd-effects.png differ diff --git a/docs/images/guition-effects-picker.png b/docs/images/guition-effects-picker.png new file mode 100644 index 0000000..019faba Binary files /dev/null and b/docs/images/guition-effects-picker.png differ diff --git a/docs/images/guition-effects.png b/docs/images/guition-effects.png new file mode 100644 index 0000000..6efbbb2 Binary files /dev/null and b/docs/images/guition-effects.png differ diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index a260690..195d4f3 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -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 diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 0afde43..75de110 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -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 diff --git a/packages/cyd.yaml b/packages/cyd.yaml index f7b39ad..dfb5e02 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -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 diff --git a/packages/guition.yaml b/packages/guition.yaml index 43e61c0..ab58ace 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -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 diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 50714f2..d9d05c6 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 2522c03..17322f1 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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': diff --git a/screen_manager/app/light_effects.py b/screen_manager/app/light_effects.py new file mode 100644 index 0000000..c4370f2 --- /dev/null +++ b/screen_manager/app/light_effects.py @@ -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) diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 3a9fecd..7e3ec6d 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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() diff --git a/screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff b/screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff similarity index 99% rename from screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff rename to screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff index 6fcdaf3..eeb874d 100644 Binary files a/screen_manager/app/static/assets/bar-roboto-400-H0q_-5Ia.woff and b/screen_manager/app/static/assets/bar-roboto-400-DWGTva7D.woff differ diff --git a/screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff b/screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff similarity index 99% rename from screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff rename to screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff index e95315b..353464b 100644 Binary files a/screen_manager/app/static/assets/bar-roboto-500-Bn7ZL9yA.woff and b/screen_manager/app/static/assets/bar-roboto-500-B8TsR_Fs.woff differ diff --git a/screen_manager/app/static/assets/index-CKR1N3aP.css b/screen_manager/app/static/assets/index-C6Sfd-UM.css similarity index 99% rename from screen_manager/app/static/assets/index-CKR1N3aP.css rename to screen_manager/app/static/assets/index-C6Sfd-UM.css index 87bee2a..6a6b914 100644 --- a/screen_manager/app/static/assets/index-CKR1N3aP.css +++ b/screen_manager/app/static/assets/index-C6Sfd-UM.css @@ -1 +1 @@ -:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-BawP5y9D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-H0q_-5Ia.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-Bn7ZL9yA.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.nav-item[aria-current=true]{background:var(--seg);color:var(--ink);font-weight:500}.nav-item .dot{width:7px;height:7px;border-radius:50%;background:var(--good);flex:none}.nav-item .dot.off{background:var(--line-strong)}.nav-item .unsaved{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:none}.nav-item .txt{display:grid;min-width:0;flex:1}.nav-item .txt small{font-size:11px;color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-item .mdi{font-size:16px;width:18px;text-align:center;color:var(--muted)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.bar-wrap.selected{outline:2px solid var(--accent);outline-offset:1px}.bar-wrap svg{display:block;width:100%;height:auto}.tiles{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:minmax(0,1fr);gap:8px;min-height:0}.tile{position:relative;background:#fff;border:1px solid #dcdcdc;border-radius:12px;padding:8px 9px;display:flex;flex-direction:column;justify-content:space-between;gap:4px;min-height:0;cursor:pointer;text-align:left;overflow:hidden;color:#1b1b1b;touch-action:manipulation}.tile:hover{border-color:#b9c4d3}.tile.chosen{outline:2px solid var(--accent);outline-offset:1px}.tile.wide{grid-column:span 2;flex-direction:row;align-items:center;gap:8px}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.filters button[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.filters .domain-icon{width:16px;height:16px;font-size:11px;border-radius:50%;display:grid;place-items:center}.lib-list{overflow:auto;padding:8px;display:grid;gap:2px;align-content:start;flex:1}.ent{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;width:100%;text-align:left;cursor:grab;touch-action:manipulation}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.set-card>.hint{margin:6px 0 4px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px} +:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-Dqh6fj0Q.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-DWGTva7D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-B8TsR_Fs.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.nav-item[aria-current=true]{background:var(--seg);color:var(--ink);font-weight:500}.nav-item .dot{width:7px;height:7px;border-radius:50%;background:var(--good);flex:none}.nav-item .dot.off{background:var(--line-strong)}.nav-item .unsaved{width:7px;height:7px;border-radius:50%;background:var(--warn);flex:none}.nav-item .txt{display:grid;min-width:0;flex:1}.nav-item .txt small{font-size:11px;color:var(--muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-item .mdi{font-size:16px;width:18px;text-align:center;color:var(--muted)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.bar-wrap.selected{outline:2px solid var(--accent);outline-offset:1px}.bar-wrap svg{display:block;width:100%;height:auto}.tiles{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:minmax(0,1fr);gap:8px;min-height:0}.tile{position:relative;background:#fff;border:1px solid #dcdcdc;border-radius:12px;padding:8px 9px;display:flex;flex-direction:column;justify-content:space-between;gap:4px;min-height:0;cursor:pointer;text-align:left;overflow:hidden;color:#1b1b1b;touch-action:manipulation}.tile:hover{border-color:#b9c4d3}.tile.chosen{outline:2px solid var(--accent);outline-offset:1px}.tile.wide{grid-column:span 2;flex-direction:row;align-items:center;gap:8px}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.filters button[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.filters .domain-icon{width:16px;height:16px;font-size:11px;border-radius:50%;display:grid;place-items:center}.lib-list{overflow:auto;padding:8px;display:grid;gap:2px;align-content:start;flex:1}.ent{display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;width:100%;text-align:left;cursor:grab;touch-action:manipulation}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.set-card>.hint{margin:6px 0 4px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px} diff --git a/screen_manager/app/static/assets/index-Cp13kZoB.js b/screen_manager/app/static/assets/index-CteHDGjl.js similarity index 100% rename from screen_manager/app/static/assets/index-Cp13kZoB.js rename to screen_manager/app/static/assets/index-CteHDGjl.js diff --git a/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff b/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff deleted file mode 100644 index 7bdb239..0000000 Binary files a/screen_manager/app/static/assets/tile-icons-BawP5y9D.woff and /dev/null differ diff --git a/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff b/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff new file mode 100644 index 0000000..90daa2b Binary files /dev/null and b/screen_manager/app/static/assets/tile-icons-Dqh6fj0Q.woff differ diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index 9009d1b..73665c8 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -5,8 +5,8 @@ ESP Screens - - + +

diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index 02e1874..d6e12c1 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -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'), diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index d3da9e5..14fa741 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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 diff --git a/tests/test_effects_page.cpp b/tests/test_effects_page.cpp new file mode 100644 index 0000000..30cbb4c --- /dev/null +++ b/tests/test_effects_page.cpp @@ -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 +#include + +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 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; +} diff --git a/tests/test_light_effects.py b/tests/test_light_effects.py new file mode 100644 index 0000000..2a35dce --- /dev/null +++ b/tests/test_light_effects.py @@ -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() diff --git a/web/src/assets/bar-roboto-400.woff b/web/src/assets/bar-roboto-400.woff index 6fcdaf3..eeb874d 100644 Binary files a/web/src/assets/bar-roboto-400.woff and b/web/src/assets/bar-roboto-400.woff differ diff --git a/web/src/assets/bar-roboto-500.woff b/web/src/assets/bar-roboto-500.woff index e95315b..353464b 100644 Binary files a/web/src/assets/bar-roboto-500.woff and b/web/src/assets/bar-roboto-500.woff differ diff --git a/web/src/assets/tile-icons.woff b/web/src/assets/tile-icons.woff index 7bdb239..90daa2b 100644 Binary files a/web/src/assets/tile-icons.woff and b/web/src/assets/tile-icons.woff differ