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 `