From 6809d3b941c528bfb6860cead01eda345794ca01 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Thu, 17 Sep 2026 15:23:03 +0200 Subject: [PATCH] Take what a tile can do, its tap actions, words and icons from Home Assistant (0.2.67) GitHub issue #7 asked for cover.toggle on tap. ESP Screens now asks Home Assistant which actions each entity has instead of keeping lists: the editor offers On / off (covers included), sliders, controls and displays only where Home Assistant has the action, and refuses only new unsupported settings. Perform action runs any action Home Assistant offers for the tile's entity, under its own names and fields; the screen sends it with a call id and says Refused when Home Assistant refuses. Sensors round by display precision, and tiles, the top bar, timelines, vacuum chips and the inspector use Home Assistant's words where they showed raw states. Tiles show Home Assistant's icons, picked as its frontend does (the fonts grow to 309 glyphs). Existing screens keep every setting and tap as before: stored layouts load leniently, legacy taps route the same (1020 combinations), and messages are byte-identical without Home Assistant's words and icons. Firmware 0.2.58. Co-Authored-By: Claude Opus 5 --- components/smart_display/runtime_model.h | 30 +- components/smart_display/runtime_tiles.h | 144 +++++++-- components/smart_display/text.py | 3 + components/smart_display/tile_controls.h | 30 ++ components/smart_display/tile_icon_names.h | 125 ++++++++ docs/EASY_SETUP.md | 7 +- docs/RELEASING.md | 119 +++++++ docs/TEST_RESULTS_0267.md | 183 +++++++++++ guition-4848s040.yaml | 127 +++++++- home-like-2432s028.yaml | 127 +++++++- packages/cyd.yaml | 127 +++++++- packages/guition.yaml | 127 +++++++- screen_manager/CHANGELOG.md | 19 ++ screen_manager/app/claude_skill.py | 5 +- screen_manager/app/core.py | 158 +++++++++- screen_manager/app/ha_catalogue.py | 348 +++++++++++++++++++++ screen_manager/app/header_bar.py | 32 +- screen_manager/app/history_card.py | 22 +- screen_manager/app/server.py | 186 ++++++++++- screen_manager/app/static/app.js | 259 ++++++++++++++- screen_manager/app/static/style.css | 14 + screen_manager/app/static/tile-icons.woff | Bin 15864 -> 24044 bytes screen_manager/app/tile_icons.py | 227 +++++++++++++- screen_manager/config.yaml | 2 +- tests/test_cover_card.py | 12 +- tests/test_extended.py | 4 +- tests/test_ha_capabilities.py | 275 ++++++++++++++++ tests/test_ha_icons.py | 124 ++++++++ tests/test_runtime_model.cpp | 8 + tests/test_state_words.py | 173 ++++++++++ tests/test_tap_actions.py | 236 ++++++++++++++ tests/test_tile_controls.cpp | 62 ++++ 32 files changed, 3210 insertions(+), 105 deletions(-) create mode 100644 docs/TEST_RESULTS_0267.md create mode 100644 screen_manager/app/ha_catalogue.py create mode 100644 tests/test_ha_capabilities.py create mode 100644 tests/test_ha_icons.py create mode 100644 tests/test_state_words.py create mode 100644 tests/test_tap_actions.py diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index 1e37a0c..959e9e1 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,15 @@ inline bool valid_entity(const std::string &entity) { if (domain == allowed) return true; return false; } +// An action as Home Assistant names it (domain.action: lowercase letters, digits, underscores), of any integration. +inline bool valid_action(const std::string &action) { + if (action.size() > 64) return false; + auto dot = action.find('.'); + if (dot == std::string::npos || dot == 0 || dot + 1 == action.size() || action.find('.', dot + 1) != std::string::npos) return false; + for (size_t i = 0; i < action.size(); ++i) + if (i != dot && !(action[i] >= 'a' && action[i] <= 'z') && !(action[i] >= '0' && action[i] <= '9') && action[i] != '_') return false; + return true; +} // A tile keeps a fingerprint (FNV-1a) of its last state message instead of a copy of it. It is also an // ArduinoJson writer: the firmware hashes the attributes while serializing them, without a string. struct Fingerprint { @@ -79,13 +89,20 @@ struct Extra { bool charging = false; // Cover (firmware 0.2.50+): the tilt of its slats, 0 closed to 100 open. float tilt = NAN; + // A tap that performs a Home Assistant action of the tile's own choosing (firmware 0.2.58+): the action, its data as + // text, and the values Home Assistant renders itself (numbers, lists, true or false) as templates. + std::string action; + std::vector> action_data, action_templates; + // Home Assistant's word for the state where the screen has none of its own (app 0.2.67+): "Open", "Playing", "Rinsing". + std::string state_word; Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; } bool empty() const { return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() && hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) && std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() && remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() && - choices.empty() && room.empty() && !charging && std::isnan(tilt); + choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() && + action_templates.empty() && state_word.empty(); } }; // The Extra of a tile on the heap, copied along with the tile like an ordinary member. @@ -128,6 +145,8 @@ struct Tile { uint32_t revision = 0, pending_revision = 0; // state_revision() fingerprints uint32_t pending_since = 0; bool pending = false, confirmed = false, local_feedback = false; + // When Home Assistant refused the action a tap sent (firmware 0.2.58+); the tile says so for a moment. + uint32_t refused_at = 0; ExtraBox extra_box; const Extra &extra() const { static const Extra none; return extra_box.ptr ? *extra_box.ptr : none; } Extra *extra_ptr() { return extra_box.ptr.get(); } @@ -151,7 +170,14 @@ struct Tile { // Two built-in cards, and only one of them is a clock that has to be redrawn every minute. bool is_clock() const { return entity == "screen.clock"; } bool is_settings() const { return entity == "screen.settings"; } - bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); } + // A scene, button or input button that never ran is "unknown" in Home Assistant, which still lets you press it + // (hui-button-entity-row disables only an unavailable one): its state is the moment it last ran (firmware 0.2.58+). + bool available() const { + if (builtin()) return true; + if (!received || state.empty() || state == "unavailable") return false; + auto d = domain(); + return state != "unknown" || d == "scene" || d == "button" || d == "input_button"; + } bool active() const { return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") || (domain() == "sun" && state == "above_horizon") || (domain() == "climate" && available() && state != "off"); diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 22b7a30..5cf7619 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -485,6 +485,24 @@ inline std::string receive(const std::string &payload) { // carry is collected in `next` and replaces the tile's Extra at the end (see Tile::set_extra). auto extra = root["x"]; Extra next; + // A tap's own Home Assistant action (app 0.2.67+): {"s": action, "d": [[key, text]], "t": [[key, template]]}. + auto act = options["act"]; + if (act.is()) { + std::string service = string(act["s"], 64); + auto pairs = [](JsonVariant list, std::vector> &out) { + if (!list.is()) return; + for (JsonVariant pair : list.as()) { + if (out.size() == 8 || !pair.is() || pair.as().size() != 2) continue; + std::string key = string(pair[0], 32); + if (!key.empty()) out.emplace_back(std::move(key), string(pair[1], 400)); + } + }; + if (valid_action(service)) { + next.action = std::move(service); + pairs(act["d"], next.action_data); + pairs(act["t"], next.action_templates); + } + } if (extra["days"].is()) for (JsonVariant day : extra["days"].as()) { if (next.forecast.size() == 5) break; next.forecast.emplace_back(); auto &f = next.forecast.back(); @@ -573,6 +591,7 @@ inline std::string receive(const std::string &payload) { 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); + next.state_word = string(extra["w"], 32); tile.set_extra(std::move(next)); // Home Assistant reports the edited value: the -/+ pill follows its state again. if(std::isfinite(tile.edit_value) && tile.edit_sent && std::fabs(tile_controls::edit_target(tile)-tile.edit_value)<0.051f)tile.edit_value=NAN; @@ -588,7 +607,51 @@ inline std::string receive(const std::string &payload) { }); return result; } -inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="") { +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +// Home Assistant answers an action sent with a call id (firmware 0.2.58+, Home Assistant 2025.10+). ESPHome keeps each +// answer's callback until the answer arrives and has no timeout of its own, so a tap asks for at most four answers at a +// time and ends the ones Home Assistant never gives (actions not allowed, an older Home Assistant) after eight seconds. +// Only a refusal shows: the tile says Refused for a moment instead of waiting. +struct WatchedCall { uint32_t id = 0, since = 0; }; +inline std::array watched_calls{}; +inline const char *const NO_ANSWER = "no answer"; +inline void watch_call(esphome::api::HomeassistantActionRequest &request, const std::string &entity) { + static uint32_t last_id = 0x5C000000u; // apart from ESPHome's own counter for YAML actions, which starts at 1 + auto slot = std::find_if(watched_calls.begin(), watched_calls.end(), [](const WatchedCall &c) { return c.id == 0; }); + if (slot == watched_calls.end()) return; + const uint32_t id = ++last_id; + *slot = {id, esphome::millis()}; + request.call_id = id; + esphome::api::global_api_server->register_action_response_callback(id, [id, entity](const esphome::api::ActionResponse &answer) { + for (auto &c : watched_calls) if (c.id == id) c = {}; + if (answer.is_success() || answer.get_error_message().c_str() == NO_ANSWER) return; + ESP_LOGW("runtime_action", "Home Assistant refused the action for %s: %.*s", entity.c_str(), + (int) answer.get_error_message().size(), answer.get_error_message().c_str()); + for (size_t i = 0; i < model.tiles.size(); ++i) if (model.tiles[i].entity == entity) { + model.tiles[i].pending = false; + model.tiles[i].refused_at = std::max(1, esphome::millis()); + refresh_tile(i); + } + }); +} +inline void expire_calls(uint32_t now) { + for (auto &c : watched_calls) { + if (!c.id || now - c.since < 8000) continue; + const uint32_t id = c.id; + c = {}; + esphome::api::global_api_server->handle_action_response(id, false, esphome::StringRef(NO_ANSWER, 9)); + } +} +#endif +// Marks every tile of the entity busy and sends; `watch` asks Home Assistant for an answer (a tap). +inline void send_action(esphome::api::HomeassistantActionRequest &request, const std::string &entity, bool watch) { + for(size_t i=0;isend_homeassistant_action(request); +} +inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="", bool watch=false) { if (!fresh() || !valid_entity(entity)) return; esphome::api::HomeassistantActionRequest request; request.service = esphome::StringRef(service); @@ -598,10 +661,38 @@ inline void action(const std::string &service, const std::string &entity, const entry.value = esphome::StringRef(entity); request.data.push_back(entry); if(!key.empty()) {esphome::api::HomeassistantServiceMap param;param.key=esphome::StringRef(key);param.value=esphome::StringRef(value);request.data.push_back(param);} - for(size_t i=0;isend_homeassistant_action(request); + send_action(request, entity, watch); ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),entity.c_str()); } +// A tap's own action (firmware 0.2.58+): the tile's entity with the data the app sent, text as data and the values Home +// Assistant renders itself (numbers, lists, true or false) as a data_template. +inline void perform(const Tile &tile) { + const auto &x = tile.extra(); + if (!fresh() || !valid_entity(tile.entity) || x.action.empty()) return; + esphome::api::HomeassistantActionRequest request; + request.service = esphome::StringRef(x.action); + request.data.init(1 + x.action_data.size()); + esphome::api::HomeassistantServiceMap target; + target.key = esphome::StringRef("entity_id"); + target.value = esphome::StringRef(tile.entity); + request.data.push_back(target); + for (const auto &pair : x.action_data) { + esphome::api::HomeassistantServiceMap entry; + entry.key = esphome::StringRef(pair.first); + entry.value = esphome::StringRef(pair.second); + request.data.push_back(entry); + } + request.data_template.init(x.action_templates.size()); + for (const auto &pair : x.action_templates) { + esphome::api::HomeassistantServiceMap entry; + entry.key = esphome::StringRef(pair.first); + entry.value = esphome::StringRef(pair.second); + request.data_template.push_back(entry); + } + send_action(request, tile.entity, true); + 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 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) { @@ -664,6 +755,8 @@ inline std::string detail_state(const Tile &t){ if(t.state=="idle")return "Idle"; if(t.state=="error")return "Check the robot in HA"; if(!t.available())return "Unavailable"; + // Home Assistant's word where the screen has none of its own (firmware 0.2.58+). + if(!t.extra().state_word.empty())return t.extra().state_word; return t.state; } inline void hide_detail(){if(detail_root)lv_obj_add_flag(detail_root,LV_OBJ_FLAG_HIDDEN);} @@ -1767,25 +1860,28 @@ inline void event(lv_event_t *event) { if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return; // A camera or an image entity opens full screen on a board that draws images (firmware 0.2.57+). if(d=="camera" || d=="image"){if(camera_supported())camera_open(tile.entity,tile.name);return;} - bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover"; - if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true; - if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false; - if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;} - if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;} - // A short tap runs or pauses the timer; holding opens the card with a cancel button. - if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;} - if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="cover" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; } - if (open) { - if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") { + // tile_controls::tap_route decides; tests/test_tile_controls.cpp keeps every older tap choice routed as before. + auto tap = tile_controls::tap_route(tile, code == LV_EVENT_LONG_PRESSED); + switch (tap.route) { + case tile_controls::TapRoute::ACTION: + action(tap.service, tile.entity, "", "", true); + return; + case tile_controls::TapRoute::CUSTOM: + perform(tile); + return; + case tile_controls::TapRoute::CARD: + if (tap.busy) tile.begin(esphome::millis(), true); active_index = w.index; - tile.begin(esphome::millis(),true); + show_detail(w.index); + return; + case tile_controls::TapRoute::OVERLAY: + active_index = w.index; + tile.begin(esphome::millis(), true); if (detail) detail(tile); - } else { active_index=w.index;show_detail(w.index); } - return; + return; + default: + return; } - if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity); - if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity); - if (d == "button" || d == "input_button") action(d + ".press", tile.entity); } // The same guard for any local style: a page switch mostly hands a slot the colours it already has, // and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition). @@ -2407,6 +2503,7 @@ inline void render_slot(size_t slot) { // Nothing in Home Assistant stands behind the settings card, so it says the same with the link down. if (t.is_settings()) value = "Tap to open"; 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" && 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; @@ -2420,6 +2517,8 @@ inline void render_slot(size_t slot) { else if (value == "off") value = "Off"; else if (value == "cleaning") value = "Cleaning"; else if (value == "docked") value = "Docked"; + // Home Assistant's word where the screen has none of its own (firmware 0.2.58+): a cover says Open, a washer Rinsing. + else if (!t.extra().state_word.empty()) value = t.extra().state_word; else if (!t.unit.empty() && !watch) value += " " + t.unit; bool pending=t.loading(esphome::millis()); if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}} @@ -3054,8 +3153,13 @@ inline void tick() { // HA dropping or returning and the feed timing out change every card at once. bool now_fresh=fresh(); if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;} +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES + expire_calls(esphome::millis()); +#endif for(size_t i=0;i=4000){t.refused_at=0;card(i);} + if(!t.pending || t.loading(esphome::millis()))continue; t.pending=false;card(i); // A vacuum chip Home Assistant never confirmed goes back to what the robot reports. bool sent=false;if(auto *x=t.extra_ptr())for(auto &c:x->choices){sent=sent||!c.sent.empty();c.sent.clear();} diff --git a/components/smart_display/text.py b/components/smart_display/text.py index 93985a1..0909195 100644 --- a/components/smart_display/text.py +++ b/components/smart_display/text.py @@ -10,6 +10,9 @@ DashboardInbox = ns.class_("DashboardInbox", text.Text, cg.Component) CONFIG_SCHEMA = text.text_schema(DashboardInbox, mode="TEXT", entity_category="config").extend(cv.COMPONENT_SCHEMA) async def to_code(config): + # A tap asks Home Assistant whether it took the action (firmware 0.2.58+): ESPHome's own action responses, which it + # otherwise only compiles in for a YAML homeassistant.action with on_success or on_error. + cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await text.register_text(var, config, min_length=0, max_length=255, pattern=None) diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h index 596d4ac..9b8e485 100644 --- a/components/smart_display/tile_controls.h +++ b/components/smart_display/tile_controls.h @@ -370,4 +370,34 @@ inline Action edit_action(const Tile &t, float value) { if (d == "number" || d == "input_number") return {d + ".set_value", "value", text}; return {}; } + +// ---- What a finger on a tile does ---- +// The tile's own `tap` choice comes first. `toggle` sends .toggle on every domain (firmware 0.2.58+): the app +// only offers it where Home Assistant lists that action for the entity, such as a cover, which stops while it moves. +// Otherwise the domain decides, as before: holding or `detail` opens a card, a short tap switches, runs or presses. +// CARD is the runtime detail card (show_detail), OVERLAY the board's own light, fan and climate card; `busy` marks the +// tile busy for a moment while the card opens. +// CUSTOM (firmware 0.2.58+) is an action of the tile's own choosing from Home Assistant's list, with its data. +enum class TapRoute : uint8_t { NONE, ACTION, CARD, OVERLAY, CUSTOM }; +struct Tap { TapRoute route = TapRoute::NONE; std::string service; bool busy = false; }; +inline bool runtime_card_domain(const std::string &d) { + return d == "sensor" || d == "binary_sensor" || d == "weather" || d == "number" || d == "input_number" || d == "select" || + d == "input_select" || d == "media_player" || d == "vacuum" || d == "cover" || d == "sun" || d == "person" || d == "timer"; +} +inline Tap tap_route(const Tile &t, bool hold) { + const std::string d = t.domain(); + if (t.tap == "none") return {}; + // A tile whose action didn't arrive (an app before 0.2.67) taps automatically, as older firmware does. + if (!hold && t.tap == "action" && !t.extra().action.empty()) return {TapRoute::CUSTOM, t.extra().action}; + if (!hold && t.tap == "toggle") return {TapRoute::ACTION, d + ".toggle"}; + bool open = hold || t.tap == "detail" || d == "climate" || d == "vacuum" || d == "cover"; + // A short tap runs or pauses the timer; holding opens the card with a cancel button. + if (d == "timer" && !open) return {TapRoute::ACTION, t.state == "active" ? "timer.pause" : "timer.start"}; + if (runtime_card_domain(d)) return {TapRoute::CARD, "", true}; + if (open) return d == "light" || d == "climate" || d == "fan" ? Tap{TapRoute::OVERLAY, "", true} : Tap{TapRoute::CARD, "", false}; + if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") return {TapRoute::ACTION, d + ".toggle"}; + if (d == "scene" || d == "script") return {TapRoute::ACTION, d + ".turn_on"}; + if (d == "button" || d == "input_button") return {TapRoute::ACTION, d + ".press"}; + return {}; +} } // namespace tile_controls diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index 9639855..4911a4a 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -191,6 +191,131 @@ inline constexpr Named NAMES[] = { {"information-outline", 0xF02FD}, {"restart", 0xF0709}, {"lightbulb-off", 0xF0E4F}, + {"account-arrow-right", 0xF0B53}, + {"air-filter", 0xF0D43}, + {"air-humidifier", 0xF1099}, + {"air-humidifier-off", 0xF1466}, + {"alert-circle", 0xF0028}, + {"angle-acute", 0xF0937}, + {"arrow-bottom-left", 0xF0042}, + {"arrow-bottom-right", 0xF0043}, + {"arrow-down-box", 0xF06C0}, + {"arrow-left-right", 0xF0E73}, + {"arrow-right", 0xF0054}, + {"arrow-split-vertical", 0xF093C}, + {"arrow-top-left", 0xF005B}, + {"arrow-top-right", 0xF005C}, + {"arrow-up-box", 0xF06C3}, + {"audio-video", 0xF093D}, + {"audio-video-off", 0xF11B6}, + {"battery", 0xF0079}, + {"battery-10", 0xF007A}, + {"battery-20", 0xF007B}, + {"battery-30", 0xF007C}, + {"battery-40", 0xF007D}, + {"battery-50", 0xF007E}, + {"battery-60", 0xF007F}, + {"battery-70", 0xF0080}, + {"battery-80", 0xF0081}, + {"battery-90", 0xF0082}, + {"battery-alert", 0xF0083}, + {"battery-charging", 0xF0084}, + {"battery-outline", 0xF008E}, + {"battery-unknown", 0xF0091}, + {"blinds-horizontal", 0xF1A2B}, + {"blinds-horizontal-closed", 0xF1A2C}, + {"bluetooth", 0xF00AF}, + {"bluetooth-connect", 0xF00B1}, + {"brightness-5", 0xF00DE}, + {"brightness-7", 0xF00E0}, + {"button-pointer", 0xF1B50}, + {"car-battery", 0xF010C}, + {"car-coolant-level", 0xF1019}, + {"cash", 0xF0114}, + {"cast-connected", 0xF0119}, + {"cast-off", 0xF078A}, + {"check-circle", 0xF05E0}, + {"check-circle-outline", 0xF05E1}, + {"check-network-outline", 0xF0C54}, + {"checkbox-marked-circle", 0xF0133}, + {"circle-slice-8", 0xF0AA5}, + {"clock", 0xF0954}, + {"clock-start", 0xF0155}, + {"close-circle-outline", 0xF015A}, + {"close-network-outline", 0xF0C5F}, + {"compass-rose", 0xF1382}, + {"crop-portrait", 0xF01A1}, + {"crosshairs-question", 0xF1136}, + {"current-ac", 0xF1480}, + {"database", 0xF01BC}, + {"ear-hearing", 0xF07C5}, + {"eye", 0xF0208}, + {"eye-check", 0xF0D04}, + {"fan-off", 0xF081D}, + {"format-list-bulleted", 0xF0279}, + {"garage-open", 0xF06DA}, + {"gate", 0xF0299}, + {"gate-open", 0xF116A}, + {"home-outline", 0xF06A1}, + {"lan-connect", 0xF0318}, + {"lan-disconnect", 0xF0319}, + {"lightning-bolt", 0xF140B}, + {"lock-alert", 0xF08EE}, + {"lock-clock", 0xF097F}, + {"lock-open", 0xF033F}, + {"molecule", 0xF0BAC}, + {"molecule-co", 0xF12FE}, + {"motion-sensor-off", 0xF1435}, + {"music-note-off", 0xF038A}, + {"octagon", 0xF03C3}, + {"package", 0xF03D3}, + {"package-up", 0xF03D5}, + {"ph", 0xF17C5}, + {"pipe-valve", 0xF184D}, + {"power-plug-off", 0xF06A6}, + {"progress-clock", 0xF0996}, + {"projector-off", 0xF1A23}, + {"radioactive", 0xF043C}, + {"ray-vertex", 0xF0445}, + {"roller-shade", 0xF1A6B}, + {"script-text-play", 0xF1727}, + {"security", 0xF0483}, + {"shield", 0xF0498}, + {"shield-airplane", 0xF06BB}, + {"shield-lock", 0xF099D}, + {"shield-moon", 0xF1828}, + {"shield-off", 0xF099E}, + {"shield-outline", 0xF0499}, + {"sine-wave", 0xF095B}, + {"smoke-detector-alert", 0xF192E}, + {"smoke-detector-variant", 0xF180B}, + {"smoke-detector-variant-alert", 0xF1930}, + {"speaker-off", 0xF04C4}, + {"speaker-pause", 0xF1B73}, + {"speaker-play", 0xF1B72}, + {"speedometer", 0xF04C5}, + {"spoon-sugar", 0xF1429}, + {"sprout-outline", 0xF0E67}, + {"square", 0xF0764}, + {"square-outline", 0xF0763}, + {"storage-tank", 0xF1A75}, + {"sun-wireless", 0xF17FE}, + {"television-off", 0xF083B}, + {"television-pause", 0xF0F89}, + {"television-play", 0xF0ECF}, + {"texture-box", 0xF0FE6}, + {"thermometer-lines", 0xF0510}, + {"toggle-switch-variant", 0xF1A25}, + {"toggle-switch-variant-off", 0xF1A26}, + {"transmission-tower", 0xF0D3E}, + {"vibrate", 0xF0566}, + {"water-boiler-off", 0xF11B4}, + {"water-off", 0xF058D}, + {"water-opacity", 0xF1855}, + {"weight", 0xF05A1}, + {"wifi", 0xF05A9}, + {"window-closed", 0xF05AE}, + {"window-open", 0xF05B1}, }; inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]); // Codepoint of a name the fonts carry; 0 for any other name. diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index db97340..92a762c 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -140,7 +140,12 @@ Click **Save & send to screen** to send your changes. - Light, switch, input_boolean, and fan: tap to turn on/off. - Long press a light: brightness, rainbow color, and white temperature, as far as the light supports those features. -- Climate, vacuum, and cover: tap to open the control card. +- Climate, vacuum, and cover: tap to open the control card. Under **On tap**, choose **On / off** + to open, close, or stop a cover with a tap instead (firmware 0.2.58+); holding it still opens the card. + ESP Screens offers **On / off**, a small slider, and direct controls only when Home Assistant has + the action for that entity. **Perform action** runs any action Home Assistant offers for the + entity, such as **Set cover position** with a position, under Home Assistant's own names + (firmware 0.2.58+). - Long press a fan: speed, if the device supports percentages. - Scene/script: tap to run; button/input_button: tap to press. - Sensor, number, binary sensor, and person: tap for the history card, for 1 hour, diff --git a/docs/RELEASING.md b/docs/RELEASING.md index af0d348..d911607 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -176,6 +176,125 @@ icons sit off-center in the browser); run `generate_packages.py` afterward. The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. +### Compatibility 0.2.67 / firmware 0.2.58 + +App (new `ha_catalogue.py`, `core.py`, `server.py`, `header_bar.py`, `history_card.py`, `tile_icons.py`, the editor and +the Claude skill), `runtime_model.h`, `tile_controls.h`, `runtime_tiles.h` and `text.py`; the app raises +`FIRMWARE_VERSION` and both profiles' icon fonts grow. The storage version and the tile protocol stay 1. A tile gains +the tap choice `action` with the option `action`; its state message gains the options `act` and `x.w`, and `o.icon` now +carries most tiles' icon. Without Home Assistant's answers everything behaves as before. + +Capabilities (what the editor offers): + +- What the editor offers per entity comes from Home Assistant. `HomeAssistant.fetch_services` keeps the whole + `get_services` answer (it kept only `esphome`), and every `service_registered`/`service_removed` refreshes it after + the usual second; only an `esphome` one still wakes the sync loop. `HomeAssistant.entity_actions` asks + `get_services_for_target` per entity (Home Assistant 2025.12+, core PR 157334) and keeps the answer until the action + list, the registry or the entity's `supported_features`/`device_class` change. A Home Assistant that answers "Unknown + command" is remembered, and `ha_catalogue.local_actions` evaluates the `target.entity` filters of the descriptions + instead: every condition of a filter must hold, a `supported_features` entry is a mask the entity must support + completely, any entry will do. On Max's Home Assistant 2026.9.2 both answers were identical for 30 of 30 entities. +- `ha_catalogue.capabilities` turns the action list into what the editor may offer: `toggle` (`.toggle`), + `inline` and `controls` (the Home Assistant action each of our widgets sends, `INLINE`/`CONTROLS`, with a field that + must pass its `filter`: a light's small slider needs `light.turn_on` with `brightness_pct`), and `displays` (graph for + a sensor `history_card.kind` draws as a line, forecast for weather with the daily bit or no features yet). `GET + /api/capabilities?entity=...` returns it for up to 40 entities, null when unknown (no action list, no state). +- `Manager.check_supported` runs before `Manager.save` for the editor's PUT and for tile events. It refuses a tap, + slider, control or display Home Assistant doesn't support only when that setting is new or changed for the tile; what + a tile already has keeps saving, and nothing is refused while the capabilities are unknown. +- `validate_layout` no longer limits `tap: toggle` to six domains (only `screen.*` has none); `check_supported` takes + that place. `validate_layout(data, stored=True)` loads the app's own data leniently: a tile whose options fail + validation (a newer app's setting after a downgrade) keeps them as stored instead of stopping the app. The editor and + tile events still validate strictly. +- Firmware: `tile_controls::tap_route` decides what a tap or hold does and `runtime_tiles::event` carries it out. `tap + == "toggle"` on a short tap sends `.toggle` for every domain before the domain's own card; before, the 13 + runtime-card domains opened their card first, so a cover could not toggle. `tests/test_tile_controls.cpp` transcribes + `event()` of firmware 0.2.56 and checks 800 combinations (22 domains x 4 tap choices x 5 states x hold): every one + routes as before, including `busy`; only a short tap with `toggle` on the 16 domains that could not have it changes. +- Firmware before 0.2.58 opens the card for `toggle` on those domains; the editor says so under On tap and keeps the + choice. The editor keeps showing a stored choice that Home Assistant no longer supports, with a warning, and hides it + otherwise; a tile that becomes wide gets the first supported direct control when the domain's default isn't there. + Hints under a field sit beside the label (`withHint`): a click inside a `