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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -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<std::pair<std::string, std::string>> 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");
|
||||
|
||||
@@ -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<JsonObject>()) {
|
||||
std::string service = string(act["s"], 64);
|
||||
auto pairs = [](JsonVariant list, std::vector<std::pair<std::string, std::string>> &out) {
|
||||
if (!list.is<JsonArray>()) return;
|
||||
for (JsonVariant pair : list.as<JsonArray>()) {
|
||||
if (out.size() == 8 || !pair.is<JsonArray>() || pair.as<JsonArray>().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<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
|
||||
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<int>() && extra["chg"].as<int>() == 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<WatchedCall, 4> 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<uint32_t>(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;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());model.tiles[i].refused_at=0;refresh_tile(i);}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
if (watch) watch_call(request, entity);
|
||||
#endif
|
||||
esphome::api::global_api_server->send_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;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());refresh_tile(i);}
|
||||
esphome::api::global_api_server->send_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<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
|
||||
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
|
||||
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
|
||||
@@ -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<model.tiles.size();++i){
|
||||
auto &t=model.tiles[i];if(!t.pending || t.loading(esphome::millis()))continue;
|
||||
auto &t=model.tiles[i];
|
||||
if(t.refused_at && esphome::millis()-t.refused_at>=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();}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <domain>.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
@@ -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` (`<domain>.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 `<domain>.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 `<label>` pressed its first button.
|
||||
- `renderResults` skips `tile: false` entities (the top bar's extra domains), and `input_button` joins the `domains`
|
||||
table and the Actions filter.
|
||||
- `Tile::available()` counts `unknown` as available for `scene`, `button` and `input_button`: their state is the moment
|
||||
they last ran, and Home Assistant's button row disables only an `unavailable` one. Since the first runtime tiles such
|
||||
a tile said Unavailable and ignored taps until it had run once from Home Assistant; it now says Never run.
|
||||
|
||||
Perform action:
|
||||
|
||||
- Stored: `options.tap = "action"` with `options.action = {"action": "domain.action", "data": {...}}`.
|
||||
`core.validate_tap_action` takes an action name of any integration, at most 8 fields, no target keys (`entity_id`,
|
||||
`device_id`, `area_id`, `floor_id`, `label_id`: the target is the tile's entity), JSON values without NaN, each at
|
||||
most 400 bytes on the wire and 800 bytes together. `validate_layout` drops `action` when the tap is anything else and
|
||||
refuses it on `screen.*`. App 0.2.67 loads such a layout leniently; 0.2.66 and older stop on it (a downgrade only).
|
||||
- On the wire `screen_options` sends `o.act = {"s": action, "d": [[key, text]], "t": [[key, template]]}` and never the
|
||||
stored `action`. Text goes as ESPHome action data; every other value as `{{ "<json>" | from_json }}`, which Home
|
||||
Assistant's ESPHome integration renders with `render_complex` back into a number, a boolean, a list or an object
|
||||
(ESPHome's action data is text only).
|
||||
- The list: `HomeAssistant.fetch_services` also reads `frontend/get_translations` (English, `services`) into
|
||||
`service_names`; `get_services` has carried only services.yaml's legacy text since 2025-10-24. `GET
|
||||
/api/entity-actions?entity=` answers `ha_catalogue.action_choices`: the actions of `entity_actions`, the entity's
|
||||
domain first, then its integration, then the rest, each with Home Assistant's name and description and the fields
|
||||
whose `filter` fits the entity (sections flattened). An action that must return data (`response.optional` false) is
|
||||
left out. A `state` selector gets the entity's values for that attribute (`<attribute>_list`, `<attribute>s`,
|
||||
`available_<attribute>s`, `supported_<attribute>s`), so a Sonos source is a choice.
|
||||
- `Manager.check_supported` refuses a new or changed action that Home Assistant doesn't offer for the entity, that only
|
||||
returns data, that sets a field Home Assistant doesn't have for it, or that leaves a required field empty. A saved,
|
||||
unchanged action keeps saving. Tile events take `action` and `data` (`tap` follows unless given); the layout sensor
|
||||
carries `tap` and `action`.
|
||||
- Firmware: `receive` parses `o.act` into `Extra::action`, `action_data` and `action_templates` (a valid action name, at
|
||||
most 8 pairs, keys up to 32 and values up to 400 bytes), so only a tile with an action holds them.
|
||||
`tile_controls::tap_route` returns `CUSTOM` for a short tap with `tap == "action"` and an action; without one it taps
|
||||
automatically, as older firmware does with the unknown value (the legacy test now routes 1020 combinations). Holding
|
||||
still opens the card. `runtime_tiles::perform` sends `entity_id` plus the data, and the templates as `data_template`.
|
||||
- Answers: `text.py` defines `USE_API_HOMEASSISTANT_ACTION_RESPONSES`, which ESPHome otherwise sets only for a YAML
|
||||
`homeassistant.action` with `on_success`/`on_error`. A tap's action (the tile's own, On / off, the automatic taps from
|
||||
`event()`, not the sliders and keys) gets a call id through `watch_call`, at most four at a time. A refusal sets
|
||||
`Tile::refused_at`: the tile says Refused for four seconds and stops waiting. A success changes nothing: the tile
|
||||
waits for the new state as before. ESPHome keeps a callback until its answer arrives and has no timeout, and Home
|
||||
Assistant never answers when the device may not perform actions or is older than 2025.10, so `expire_calls` ends a
|
||||
call after eight seconds through `handle_action_response(id, false, "no answer")`, which shows nothing.
|
||||
|
||||
Numbers, words and icons:
|
||||
|
||||
- `core.rounded_state` rounds a sensor's state in `state_message` by the entity registry's display precision
|
||||
(`header_bar.precision_of`: `display_precision`, else `suggested_display_precision`), half up like the frontend,
|
||||
without thousands separators because the firmware parses the number, and "-0.0" as "0.0". No precision keeps the state
|
||||
as it is, as Home Assistant does. Numbers, climate and other domains are unchanged.
|
||||
- `HomeAssistant.fetch_services` also reads `frontend/get_translations` (English) for `entity_component` (about 63 KB on
|
||||
Max's installation) and `entity` (about 215 KB) into `state_words`, on connect and when actions register.
|
||||
`core.state_word` picks a word as the frontend's `computeStateDisplay` does: the integration's word for the entity's
|
||||
translation key (`component.<platform>.entity.<domain>.<key>.state.<state>`), then the domain's word for the state's
|
||||
device class, then the domain's word; `core.attribute_word` does the same for
|
||||
`state_attributes.<attribute>.state.<value>` (`computeAttributeValueToParts`). `ha_catalogue.screen_word` sends a word
|
||||
as `x.w` only for cover, media player, vacuum, select, input select and non-numeric sensors, folded to the tile fonts'
|
||||
glyphs (`header_bar.clean_text`), at most 32 bytes, and only when it differs from the state.
|
||||
- The same words, add-on only (any firmware): `header_bar.value` for a select, input select or input text and for a
|
||||
state without a word in `STATES` (which stays: Returning, Auto, Up); `history_card.state_label` for timeline legends
|
||||
and heading words, after `STATES`; `ha_catalogue.chip_words` for a vacuum card's `l` labels of a mode or water select
|
||||
option and of a fan speed (`attribute_word` `fan_speed`) that `VACUUM_LABELS` lacks; the inspector's `word`. Without
|
||||
translations every message is as before.
|
||||
- Firmware keeps `x.w` in `Extra::state_word` (heap only for such tiles). The tile value uses it after the screen's own
|
||||
words (Unavailable, a light's brightness, a climate's setpoint, person, sun, timer, scene, script, button, binary
|
||||
sensor, On, Off, Cleaning, Docked) and before the unit; `detail_state` after the vacuum words and Unavailable. Wide
|
||||
cover and media tiles keep `tile_controls::status_text`, and the cover card its own status line.
|
||||
- Binary sensors keep firmware 0.2.53's words (Motion / No motion where Home Assistant says Detected / Clear) until Max
|
||||
decides. Older firmware ignores `x.w`.
|
||||
- Icons: `tile_icons.HA_DEFAULTS` adds the 125 icons the fonts lacked that Home Assistant 2026.9 shows for the tile and
|
||||
top bar domains: every default, state and range icon of `frontend/get_icons` (`entity_component`) and those its
|
||||
frontend picks in code (`stateIcon`: a tracker on a router or Bluetooth). `circle` and `radiobox-blank` are not in
|
||||
this repo's MDI font. `tools/generate_icons.py` puts them in both profiles' three icon fonts and the editor font (309
|
||||
glyphs); they are not pickable. `HomeAssistant.fetch_services` reads `frontend/get_icons` (`entity_component`,
|
||||
`entity`) into `tile_icons.use_ha_icons`. `tile_icons.ha_default_icon` follows the frontend's `getEntityIcon`
|
||||
(src/data/icons.ts): the integration's icon for the translation key, then `stateIcon`, then the domain's icon for the
|
||||
state's device class or else `_`; within one, the state's icon, then a range step for a number (`Number()`, the
|
||||
highest step not above it), then the default. `default_glyph` returns a codepoint only for a glyph the fonts carry,
|
||||
never for weather, sun or `screen.*`. `core.tile_icon` uses it after a chosen icon and the entity's own `icon`
|
||||
attribute, so `o.icon` now carries most tiles' icon; `header_bar.auto_icon` and the editor's entity list (`discover`)
|
||||
use the same before their tables. Firmware before 0.2.58 drops a codepoint its fonts lack (`has_icon_glyph`) and shows
|
||||
its own default. Without icon resources (a Home Assistant before 2024.2, a refused request) everything is as before.
|
||||
|
||||
### Compatibility 0.2.66 / firmware 0.2.57
|
||||
|
||||
Camera images on the Guition (docs/CAMERA.md). Storage, keys, preferences and the settings block are unchanged; the
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Test results app 0.2.67 / firmware 0.2.58 (2026-09-17)
|
||||
|
||||
GitHub issue #7 asked for a specific action on tap, such as `cover.toggle` instead of the cover card. Max wanted the
|
||||
answer taken from Home Assistant rather than from lists in ESP Screens, the same for names, words and icons, and
|
||||
existing screens untouched. This release offers what Home Assistant says each entity can do (On / off for covers among
|
||||
it), lets a tap perform any action Home Assistant offers under its own names, and shows Home Assistant's numbers, words
|
||||
and icons. See CHANGELOG 0.2.67 and the compatibility note in docs/RELEASING.md. Built on 0.2.66 (`d60a0f1`).
|
||||
|
||||
## Home Assistant, read first
|
||||
|
||||
- Home Assistant core: `cover.toggle` needs OPEN+CLOSE and stops a moving cover; `media_player.toggle` needs
|
||||
TURN_ON+TURN_OFF; `climate.toggle` either. `get_services_for_target` exists since 2025.12 (PR 157334). `get_services`
|
||||
has carried only untranslated names since 2025-10-24; the names come from `frontend/get_translations`.
|
||||
- Home Assistant frontend: the tile card's tap defaults to more-info and hides Toggle where `canToggleState` says no;
|
||||
the button row disables a button only when it is `unavailable`, not `unknown`.
|
||||
- Frontend `src/common/entity/compute_state_display.ts` and `compute_attribute_display.ts`: a state's word is the
|
||||
integration's for the translation key, then the domain's for the state's device class, then the domain's, else the raw
|
||||
state; an attribute value's (a robot's fan speed) the same under `state_attributes.<attribute>`.
|
||||
- Frontend `src/data/icons.ts` (`getEntityIcon`) and `src/common/entity/state_icon.ts`: the entity's own icon first (the
|
||||
registry's, which Home Assistant also writes into the state's `icon`), then the integration's icon for the translation
|
||||
key, then the icons the frontend picks in code (a tracker on a router or Bluetooth, an input_datetime with only a date
|
||||
or a time, an update, the sun), then the domain's icon for the state's device class, else `_`. Within one entry: the
|
||||
state's icon, then for a number the highest `range` step not above it (below the lowest step the default), then the
|
||||
default. Home Assistant 2026.9.2 has range icons for two sensor classes: battery (a step every 10 %) and wind
|
||||
direction.
|
||||
- Max's dashboard (read only, nothing saved): Tap behaviour, Icon tap behaviour and Add interaction (hold, double tap).
|
||||
- Max's Home Assistant 2026.9.2 (read only): `media_player.sonos_badkamer` has `supported_features` 8321599, without
|
||||
TURN_ON or TURN_OFF, while ESP Screens offered On / off for every media player.
|
||||
|
||||
## Automated
|
||||
|
||||
- Python (`.venv-portal`): 377 tests OK (337 on 0.2.66).
|
||||
- New `tests/test_ha_capabilities.py` (12): target filters and field filters on real action descriptions, what the
|
||||
editor offers per entity, refusing only new settings, lenient loading of a newer app's setting, the per-entity cache
|
||||
and its invalidation, the fallback for a Home Assistant that answers "Unknown command", tile events, and `GET
|
||||
/api/capabilities`. `test_extended` and `test_cover_card` follow the new rules.
|
||||
- New `tests/test_tap_actions.py` (12): what a stored action may hold (names of any integration, field limits, no
|
||||
target keys, no NaN), dropping a stale action, the wire form (text as data, numbers, booleans and lists as
|
||||
`from_json` templates that decode back to the value), tile events and the layout sensor, Home Assistant's names and
|
||||
per-entity fields in the list (a cover without SPEED gets no speed field), the values a `state` selector offers (a
|
||||
Sonos source list), leaving out actions that only return data, what a save refuses (not offered, a missing required
|
||||
field, an unknown field, data-only actions) and what it keeps (a saved action Home Assistant dropped later), the
|
||||
translations fetch and its fallback, Claude's tile event, and `GET /api/entity-actions`.
|
||||
- New `tests/test_state_words.py` (9): rounding by display precision (half up, no thousands separator, "-0.0",
|
||||
non-numbers and missing precisions unchanged, sensors only), Home Assistant's word picked as its frontend does with
|
||||
real entries of Home Assistant 2026.9.2, words only where the screen showed the raw state and never for numbers or
|
||||
binary sensors, the tile message carrying the rounded value and `x.w`, the top bar and its editor preview ("Vacuum
|
||||
and mop", while Returning, Auto and Motion stay), a history timeline's legend and heading words, a vacuum card's
|
||||
chips ("Off (raised brush)", while Normal stays, values unchanged), and everything as before without translations.
|
||||
- New `tests/test_ha_icons.py` (7): the frontend's order with real entries (a closed blind, a playing speaker, an open
|
||||
door, an integration's icon first, the device class from the state), batteries and wind directions by their range
|
||||
steps (45 % `battery-40`, 5 % `battery-alert`, -1 and `unavailable` `battery-unknown`, 2.1e2 degrees
|
||||
`arrow-top-right`), the icons the frontend picks in code, only glyphs the fonts carry reaching the screen (none for
|
||||
weather, sun and screen tiles), every default, state and range icon of those entries in the fonts, the tile, the top
|
||||
bar and the editor list showing the same icon, and everything as before without Home Assistant's icons.
|
||||
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 17/17 PASS. `test_tile_controls` transcribes `event()` of
|
||||
firmware 0.2.56 and routes 1020 combinations (22 domains x auto/detail/toggle/none/action x 5 states x tap/hold, and
|
||||
`action` without an action) through the new `tap_route`: every one the same, `busy` included; only a short tap with
|
||||
`toggle` on the 16 domains that could not have it now sends `<domain>.toggle`, and a tile with an action gets
|
||||
`CUSTOM`. `valid_action` takes names of any integration. `test_runtime_model`: a scene, button or input button that
|
||||
never ran is available; an unknown light is not.
|
||||
- `generate_icons.py --check` (150 pickable icons, 309 glyphs) and `generate_packages.py --check` green.
|
||||
|
||||
## Existing screens stay as they are
|
||||
|
||||
- `local_actions` against Home Assistant's own `get_services_for_target` on Max's installation: identical action lists
|
||||
for 30 of 30 entities (covers, media players, lights, climate, scripts, scenes, buttons, numbers, selects, a timer).
|
||||
- Four stored layouts (two production screens saved from the bench, the demo's two screens) load the same with the old
|
||||
`validate_layout`, the new one and the new one with `stored=True`, and all 49 tile messages encode byte for byte the
|
||||
same as with `core.py` of 0.2.65 and of 0.2.66 when Home Assistant gives no words or icons.
|
||||
|
||||
## Firmware on the Mac (host builds, driven over the ESPHome API)
|
||||
|
||||
`tap_host.py` sends a layout to host builds of both packages (own device names, API ports 6167 and 6168), with every tap
|
||||
choice in the state messages themselves, taps and holds tiles like a finger does, and captures the Home Assistant
|
||||
actions the screen sends. The test client answers the screen's calls like Home Assistant does
|
||||
(`send_homeassistant_action_response`). **Guition 26/26, CYD 26/26**, on builds with the final fonts.
|
||||
|
||||
| Check | Guition | CYD |
|
||||
|---|---|---|
|
||||
| Tap on a garage door with On / off: `cover.toggle` with its `entity_id`, no card | pass | pass |
|
||||
| Hold that garage door: its card opens, nothing is sent | pass | pass |
|
||||
| Tap on a cover without On / off: its card opens, nothing is sent | pass | pass |
|
||||
| Tap on a lamp, a switch: `light.toggle`, `switch.toggle` | pass | pass |
|
||||
| Tap on a running timer, a scene: `timer.pause`, `scene.turn_on` | pass | pass |
|
||||
| Tap on an input button that never ran (`unknown`): `input_button.press` (before: ignored, "Unavailable") | pass | pass |
|
||||
| A saved On / off on a speaker: still `media_player.toggle` | pass | pass |
|
||||
| Tap on a sensor: its card opens, nothing is sent | pass | pass |
|
||||
| Tap on the skylight set to Set cover position 50: `cover.set_cover_position`, `entity_id` in data, `position: {{ "50" \| from_json }}` in data_template, a call id, no card | pass | pass |
|
||||
| Home Assistant refuses: the tile says Refused, and its state again after four seconds | pass | pass |
|
||||
| Holding the skylight opens its card and sends nothing | pass | pass |
|
||||
| Home Assistant succeeds: the tile keeps waiting for the state, no Refused | pass | pass |
|
||||
| No answer: the call is watched, and let go after eight seconds without Refused | pass | pass |
|
||||
| A status sensor with `x.w` shows "Rinsing"; without a word its state as before ("spinning"); a cover that opens "Opening" | pass | pass |
|
||||
| No errors in the log | pass | pass |
|
||||
|
||||
## ESP Screens
|
||||
|
||||
The real add-on from this tree on a demo home whose fake Home Assistant answers from Max's real action descriptions,
|
||||
English action names, translations and icon resources, in the browser pane:
|
||||
|
||||
- Opening the kitchen screen asks `GET /api/capabilities` once for its 15 tiles (200).
|
||||
- Sonos with a saved On / off: the choice stays, with the warning "Home Assistant can't turn this on and off, so a tap
|
||||
does nothing. Choose another option." Choosing Automatic removes On / off and the warning; saving: PUT 200.
|
||||
- TV (on and off): On / off offered. Garage door: On / off offered, no small slider; choosing On / off on this screen's
|
||||
firmware 0.2.33 shows "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap
|
||||
opens its card."
|
||||
- Skylight (position only): no On / off, a small slider; made double-width it takes Position slider, and the direct
|
||||
control offers only Position slider and None.
|
||||
- Cooker hood light with a saved small slider, washing machine status with a saved graph: both kept, with a warning.
|
||||
Clicking the warning text changes nothing (clicking the old hint inside a label pressed the first choice: reproduced
|
||||
with the old markup).
|
||||
- The skylight, On tap → Perform action: the list shows Set cover position and Home Assistant's general actions, each
|
||||
with its id and description; open, close and toggle are not there because the skylight lacks them. The screen's old
|
||||
firmware adds "The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works
|
||||
as Automatic."
|
||||
- Choosing Set cover position shows Position (%) with "Target position." and "Home Assistant needs Position." until 50
|
||||
is typed. Saving: PUT 200, and the skylight's state message carries
|
||||
`{"tap":"action","act":{"s":"cover.set_cover_position","t":[["position","{{ \"50\" | from_json }}"]]}}`.
|
||||
- The living room's Sonos: searching "source" leaves Select media player source; its Source field offers the speaker's
|
||||
own sources (Not set, TV, NPO Radio 2, Qmusic) and "Home Assistant needs Source." until one is chosen. The state
|
||||
message then carries `"act":{"s":"media_player.select_source","d":[["source","Qmusic"]]}`.
|
||||
- The ceiling light, Turn on light: Color is a colour picker, Color temperature and Brightness are numbers, Color name a
|
||||
list, the object fields text with Home Assistant's examples; Transition, Effect and Flash are not offered because the
|
||||
light lacks those features. Orange at 80 % sends `"t":[["rgb_color","{{ \"[255,136,0]\" | from_json
|
||||
}}"],["brightness_pct","{{ \"80\" | from_json }}"]]`. Every field has Home Assistant's field name as its accessible
|
||||
name.
|
||||
- `GET /api/inventory` (with a phone battery and tracker added) gives the battery at 64 % `battery-60`, the phone
|
||||
tracked by the router `lan-connect`, the playing Sonos `speaker-play`, the TV that is off `television-off`, the closed
|
||||
garage door `garage`, the skylight `window-open`, the cooker hood light that is off `lightbulb-off`, the living room
|
||||
climate `thermostat`, the washing machine status Home Assistant's `eye`, and no icon for the weather, which the screen
|
||||
draws. The kitchen screen's mockup draws the same icons on its tiles.
|
||||
- Inspector → Read current data: every tile's status in Home Assistant's words (Partly cloudy, Active, On, Playing,
|
||||
Home, Off, Closed, Open); numbers, a scene's time and states without a word (the demo washing machine's rinsing, an
|
||||
input button's unknown) as they come.
|
||||
- The entity list: "front door" finds the door sensor but no longer the lock; the input button has the Action badge and
|
||||
shows under Actions. Saving the changed kitchen screen: PUT 200, "Saved. Your screen is being updated."
|
||||
- No console errors apart from the live update stream that dropped while the demo server restarted.
|
||||
|
||||
## Real Home Assistant, read only
|
||||
|
||||
The app's own `HomeAssistant` class and lookups against Max's Home Assistant 2026.9.2:
|
||||
|
||||
- 79 action domains and 1571 action names; `get_services_for_target` answered, eight entities in 30 ms from the Mac and
|
||||
0.2 ms from the cache. Sonos: no On / off, volume and playback controls. Curtains: On / off, buttons and position.
|
||||
Cooker hood light (on/off only): On / off, no small slider.
|
||||
- Perform action: Curtains list Close cover, Open cover, Set cover position (Position, required), Stop cover, Toggle
|
||||
cover, then the general actions. Sonos Badkamer: 29 actions, its media player actions first, Select media player
|
||||
source among them with Source as a choice of its own stations. Bathroom light: Toggle light, Turn off light and Turn
|
||||
on light with Transition, Color, Color temperature, Brightness, Effect and more for its colour modes.
|
||||
- Words: of 743 entities a tile or the top bar can show, 11 read differently in the top bar, all as Home Assistant shows
|
||||
them: a washing machine's "Stop" is "Stopped", an Oral-B brushing mode "Daily_clean" is "Daily clean", a Voice
|
||||
satellite's "no_wake_word" is "No wake word", a select's "0" is "Off". The Roborock S8's chips stay as they were:
|
||||
every option it offers has a short label of the screen's own.
|
||||
- Icons: of the same 743 entities, 458 now get Home Assistant's icon on the screen, where they had the screen's own
|
||||
before. 52 keep their own icon and 3 are drawn by the screen (weather, sun). The other 230 keep the screen's icon as
|
||||
before: 154 whose own icon the fonts lack, and 76 whose integration's icon they lack (Sonos, Roborock, WLED, Oral-B,
|
||||
EZVIZ and others). All 29 battery sensors follow their level: 35 % `battery-30`, 55 % `battery-50`, 100 % `battery`,
|
||||
unavailable `battery-unknown`.
|
||||
|
||||
## Builds
|
||||
|
||||
Check profiles with the generated packages and local components, ESPHome 2026.6.2 (the app's own); no errors or compiler
|
||||
warnings.
|
||||
|
||||
| Profile | RAM | Flash | vs 0.2.66 |
|
||||
|---|---|---|---|
|
||||
| CYD | 23.9% (78,284 B) | 79.1% (1,452,291 B) | +344 B RAM, +28,476 B flash |
|
||||
| Guition | 25.6% (83,860 B) | 21.0% (1,707,603 B) | +344 B RAM, +42,688 B flash |
|
||||
|
||||
The RAM is the action answers (a watch list of four calls) and the words; the icons cost about 25 KB (CYD) and 39 KB
|
||||
(Guition) of flash, and the CYD keeps 383 KB free in its app partition.
|
||||
|
||||
ESPHome 2026.8.1 (what Device Builder installs now) builds both profiles too: CYD flash 79.6% (1,461,375 B), Guition
|
||||
21.2% (1,720,695 B). Its newer compiler warns about `JsonVariant` loops and `%d` with `int32_t`, all in code 0.2.66
|
||||
already had.
|
||||
|
||||
## Not tested
|
||||
|
||||
- Physical screens: not flashed (another session was working on the bench screens). A real tap on a cover with On / off,
|
||||
a real Perform action and refusal, and the new icons on the panels themselves are Max's check after the update.
|
||||
- The page in Home Assistant's Ingress frame, and the app on the Yellow (the capability lookups there are slower than
|
||||
from the Mac).
|
||||
+126
-1
@@ -2,7 +2,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.57"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.58"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -1089,6 +1089,131 @@ font:
|
||||
- "\U000F02FD" # information-outline
|
||||
- "\U000F0709" # restart
|
||||
- "\U000F0E4F" # lightbulb-off
|
||||
- "\U000F0B53" # account-arrow-right
|
||||
- "\U000F0D43" # air-filter
|
||||
- "\U000F1099" # air-humidifier
|
||||
- "\U000F1466" # air-humidifier-off
|
||||
- "\U000F0028" # alert-circle
|
||||
- "\U000F0937" # angle-acute
|
||||
- "\U000F0042" # arrow-bottom-left
|
||||
- "\U000F0043" # arrow-bottom-right
|
||||
- "\U000F06C0" # arrow-down-box
|
||||
- "\U000F0E73" # arrow-left-right
|
||||
- "\U000F0054" # arrow-right
|
||||
- "\U000F093C" # arrow-split-vertical
|
||||
- "\U000F005B" # arrow-top-left
|
||||
- "\U000F005C" # arrow-top-right
|
||||
- "\U000F06C3" # arrow-up-box
|
||||
- "\U000F093D" # audio-video
|
||||
- "\U000F11B6" # audio-video-off
|
||||
- "\U000F0079" # battery
|
||||
- "\U000F007A" # battery-10
|
||||
- "\U000F007B" # battery-20
|
||||
- "\U000F007C" # battery-30
|
||||
- "\U000F007D" # battery-40
|
||||
- "\U000F007E" # battery-50
|
||||
- "\U000F007F" # battery-60
|
||||
- "\U000F0080" # battery-70
|
||||
- "\U000F0081" # battery-80
|
||||
- "\U000F0082" # battery-90
|
||||
- "\U000F0083" # battery-alert
|
||||
- "\U000F0084" # battery-charging
|
||||
- "\U000F008E" # battery-outline
|
||||
- "\U000F0091" # battery-unknown
|
||||
- "\U000F1A2B" # blinds-horizontal
|
||||
- "\U000F1A2C" # blinds-horizontal-closed
|
||||
- "\U000F00AF" # bluetooth
|
||||
- "\U000F00B1" # bluetooth-connect
|
||||
- "\U000F00DE" # brightness-5
|
||||
- "\U000F00E0" # brightness-7
|
||||
- "\U000F1B50" # button-pointer
|
||||
- "\U000F010C" # car-battery
|
||||
- "\U000F1019" # car-coolant-level
|
||||
- "\U000F0114" # cash
|
||||
- "\U000F0119" # cast-connected
|
||||
- "\U000F078A" # cast-off
|
||||
- "\U000F05E0" # check-circle
|
||||
- "\U000F05E1" # check-circle-outline
|
||||
- "\U000F0C54" # check-network-outline
|
||||
- "\U000F0133" # checkbox-marked-circle
|
||||
- "\U000F0AA5" # circle-slice-8
|
||||
- "\U000F0954" # clock
|
||||
- "\U000F0155" # clock-start
|
||||
- "\U000F015A" # close-circle-outline
|
||||
- "\U000F0C5F" # close-network-outline
|
||||
- "\U000F1382" # compass-rose
|
||||
- "\U000F01A1" # crop-portrait
|
||||
- "\U000F1136" # crosshairs-question
|
||||
- "\U000F1480" # current-ac
|
||||
- "\U000F01BC" # database
|
||||
- "\U000F07C5" # ear-hearing
|
||||
- "\U000F0208" # eye
|
||||
- "\U000F0D04" # eye-check
|
||||
- "\U000F081D" # fan-off
|
||||
- "\U000F0279" # format-list-bulleted
|
||||
- "\U000F06DA" # garage-open
|
||||
- "\U000F0299" # gate
|
||||
- "\U000F116A" # gate-open
|
||||
- "\U000F06A1" # home-outline
|
||||
- "\U000F0318" # lan-connect
|
||||
- "\U000F0319" # lan-disconnect
|
||||
- "\U000F140B" # lightning-bolt
|
||||
- "\U000F08EE" # lock-alert
|
||||
- "\U000F097F" # lock-clock
|
||||
- "\U000F033F" # lock-open
|
||||
- "\U000F0BAC" # molecule
|
||||
- "\U000F12FE" # molecule-co
|
||||
- "\U000F1435" # motion-sensor-off
|
||||
- "\U000F038A" # music-note-off
|
||||
- "\U000F03C3" # octagon
|
||||
- "\U000F03D3" # package
|
||||
- "\U000F03D5" # package-up
|
||||
- "\U000F17C5" # ph
|
||||
- "\U000F184D" # pipe-valve
|
||||
- "\U000F06A6" # power-plug-off
|
||||
- "\U000F0996" # progress-clock
|
||||
- "\U000F1A23" # projector-off
|
||||
- "\U000F043C" # radioactive
|
||||
- "\U000F0445" # ray-vertex
|
||||
- "\U000F1A6B" # roller-shade
|
||||
- "\U000F1727" # script-text-play
|
||||
- "\U000F0483" # security
|
||||
- "\U000F0498" # shield
|
||||
- "\U000F06BB" # shield-airplane
|
||||
- "\U000F099D" # shield-lock
|
||||
- "\U000F1828" # shield-moon
|
||||
- "\U000F099E" # shield-off
|
||||
- "\U000F0499" # shield-outline
|
||||
- "\U000F095B" # sine-wave
|
||||
- "\U000F192E" # smoke-detector-alert
|
||||
- "\U000F180B" # smoke-detector-variant
|
||||
- "\U000F1930" # smoke-detector-variant-alert
|
||||
- "\U000F04C4" # speaker-off
|
||||
- "\U000F1B73" # speaker-pause
|
||||
- "\U000F1B72" # speaker-play
|
||||
- "\U000F04C5" # speedometer
|
||||
- "\U000F1429" # spoon-sugar
|
||||
- "\U000F0E67" # sprout-outline
|
||||
- "\U000F0764" # square
|
||||
- "\U000F0763" # square-outline
|
||||
- "\U000F1A75" # storage-tank
|
||||
- "\U000F17FE" # sun-wireless
|
||||
- "\U000F083B" # television-off
|
||||
- "\U000F0F89" # television-pause
|
||||
- "\U000F0ECF" # television-play
|
||||
- "\U000F0FE6" # texture-box
|
||||
- "\U000F0510" # thermometer-lines
|
||||
- "\U000F1A25" # toggle-switch-variant
|
||||
- "\U000F1A26" # toggle-switch-variant-off
|
||||
- "\U000F0D3E" # transmission-tower
|
||||
- "\U000F0566" # vibrate
|
||||
- "\U000F11B4" # water-boiler-off
|
||||
- "\U000F058D" # water-off
|
||||
- "\U000F1855" # water-opacity
|
||||
- "\U000F05A1" # weight
|
||||
- "\U000F05A9" # wifi
|
||||
- "\U000F05AE" # window-closed
|
||||
- "\U000F05B1" # window-open
|
||||
|
||||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||||
id: materialdesign_icons_mini
|
||||
|
||||
+126
-1
@@ -17,7 +17,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.57"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.58"
|
||||
# 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.
|
||||
@@ -1118,6 +1118,131 @@ font:
|
||||
- "\U000F02FD" # information-outline
|
||||
- "\U000F0709" # restart
|
||||
- "\U000F0E4F" # lightbulb-off
|
||||
- "\U000F0B53" # account-arrow-right
|
||||
- "\U000F0D43" # air-filter
|
||||
- "\U000F1099" # air-humidifier
|
||||
- "\U000F1466" # air-humidifier-off
|
||||
- "\U000F0028" # alert-circle
|
||||
- "\U000F0937" # angle-acute
|
||||
- "\U000F0042" # arrow-bottom-left
|
||||
- "\U000F0043" # arrow-bottom-right
|
||||
- "\U000F06C0" # arrow-down-box
|
||||
- "\U000F0E73" # arrow-left-right
|
||||
- "\U000F0054" # arrow-right
|
||||
- "\U000F093C" # arrow-split-vertical
|
||||
- "\U000F005B" # arrow-top-left
|
||||
- "\U000F005C" # arrow-top-right
|
||||
- "\U000F06C3" # arrow-up-box
|
||||
- "\U000F093D" # audio-video
|
||||
- "\U000F11B6" # audio-video-off
|
||||
- "\U000F0079" # battery
|
||||
- "\U000F007A" # battery-10
|
||||
- "\U000F007B" # battery-20
|
||||
- "\U000F007C" # battery-30
|
||||
- "\U000F007D" # battery-40
|
||||
- "\U000F007E" # battery-50
|
||||
- "\U000F007F" # battery-60
|
||||
- "\U000F0080" # battery-70
|
||||
- "\U000F0081" # battery-80
|
||||
- "\U000F0082" # battery-90
|
||||
- "\U000F0083" # battery-alert
|
||||
- "\U000F0084" # battery-charging
|
||||
- "\U000F008E" # battery-outline
|
||||
- "\U000F0091" # battery-unknown
|
||||
- "\U000F1A2B" # blinds-horizontal
|
||||
- "\U000F1A2C" # blinds-horizontal-closed
|
||||
- "\U000F00AF" # bluetooth
|
||||
- "\U000F00B1" # bluetooth-connect
|
||||
- "\U000F00DE" # brightness-5
|
||||
- "\U000F00E0" # brightness-7
|
||||
- "\U000F1B50" # button-pointer
|
||||
- "\U000F010C" # car-battery
|
||||
- "\U000F1019" # car-coolant-level
|
||||
- "\U000F0114" # cash
|
||||
- "\U000F0119" # cast-connected
|
||||
- "\U000F078A" # cast-off
|
||||
- "\U000F05E0" # check-circle
|
||||
- "\U000F05E1" # check-circle-outline
|
||||
- "\U000F0C54" # check-network-outline
|
||||
- "\U000F0133" # checkbox-marked-circle
|
||||
- "\U000F0AA5" # circle-slice-8
|
||||
- "\U000F0954" # clock
|
||||
- "\U000F0155" # clock-start
|
||||
- "\U000F015A" # close-circle-outline
|
||||
- "\U000F0C5F" # close-network-outline
|
||||
- "\U000F1382" # compass-rose
|
||||
- "\U000F01A1" # crop-portrait
|
||||
- "\U000F1136" # crosshairs-question
|
||||
- "\U000F1480" # current-ac
|
||||
- "\U000F01BC" # database
|
||||
- "\U000F07C5" # ear-hearing
|
||||
- "\U000F0208" # eye
|
||||
- "\U000F0D04" # eye-check
|
||||
- "\U000F081D" # fan-off
|
||||
- "\U000F0279" # format-list-bulleted
|
||||
- "\U000F06DA" # garage-open
|
||||
- "\U000F0299" # gate
|
||||
- "\U000F116A" # gate-open
|
||||
- "\U000F06A1" # home-outline
|
||||
- "\U000F0318" # lan-connect
|
||||
- "\U000F0319" # lan-disconnect
|
||||
- "\U000F140B" # lightning-bolt
|
||||
- "\U000F08EE" # lock-alert
|
||||
- "\U000F097F" # lock-clock
|
||||
- "\U000F033F" # lock-open
|
||||
- "\U000F0BAC" # molecule
|
||||
- "\U000F12FE" # molecule-co
|
||||
- "\U000F1435" # motion-sensor-off
|
||||
- "\U000F038A" # music-note-off
|
||||
- "\U000F03C3" # octagon
|
||||
- "\U000F03D3" # package
|
||||
- "\U000F03D5" # package-up
|
||||
- "\U000F17C5" # ph
|
||||
- "\U000F184D" # pipe-valve
|
||||
- "\U000F06A6" # power-plug-off
|
||||
- "\U000F0996" # progress-clock
|
||||
- "\U000F1A23" # projector-off
|
||||
- "\U000F043C" # radioactive
|
||||
- "\U000F0445" # ray-vertex
|
||||
- "\U000F1A6B" # roller-shade
|
||||
- "\U000F1727" # script-text-play
|
||||
- "\U000F0483" # security
|
||||
- "\U000F0498" # shield
|
||||
- "\U000F06BB" # shield-airplane
|
||||
- "\U000F099D" # shield-lock
|
||||
- "\U000F1828" # shield-moon
|
||||
- "\U000F099E" # shield-off
|
||||
- "\U000F0499" # shield-outline
|
||||
- "\U000F095B" # sine-wave
|
||||
- "\U000F192E" # smoke-detector-alert
|
||||
- "\U000F180B" # smoke-detector-variant
|
||||
- "\U000F1930" # smoke-detector-variant-alert
|
||||
- "\U000F04C4" # speaker-off
|
||||
- "\U000F1B73" # speaker-pause
|
||||
- "\U000F1B72" # speaker-play
|
||||
- "\U000F04C5" # speedometer
|
||||
- "\U000F1429" # spoon-sugar
|
||||
- "\U000F0E67" # sprout-outline
|
||||
- "\U000F0764" # square
|
||||
- "\U000F0763" # square-outline
|
||||
- "\U000F1A75" # storage-tank
|
||||
- "\U000F17FE" # sun-wireless
|
||||
- "\U000F083B" # television-off
|
||||
- "\U000F0F89" # television-pause
|
||||
- "\U000F0ECF" # television-play
|
||||
- "\U000F0FE6" # texture-box
|
||||
- "\U000F0510" # thermometer-lines
|
||||
- "\U000F1A25" # toggle-switch-variant
|
||||
- "\U000F1A26" # toggle-switch-variant-off
|
||||
- "\U000F0D3E" # transmission-tower
|
||||
- "\U000F0566" # vibrate
|
||||
- "\U000F11B4" # water-boiler-off
|
||||
- "\U000F058D" # water-off
|
||||
- "\U000F1855" # water-opacity
|
||||
- "\U000F05A1" # weight
|
||||
- "\U000F05A9" # wifi
|
||||
- "\U000F05AE" # window-closed
|
||||
- "\U000F05B1" # window-open
|
||||
|
||||
- file: 'fonts/materialdesignicons-webfont.ttf'
|
||||
id: materialdesign_icons_mini
|
||||
|
||||
+126
-1
@@ -18,7 +18,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.57"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.58"
|
||||
# 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.
|
||||
@@ -1107,6 +1107,131 @@ font:
|
||||
- "\U000F02FD" # information-outline
|
||||
- "\U000F0709" # restart
|
||||
- "\U000F0E4F" # lightbulb-off
|
||||
- "\U000F0B53" # account-arrow-right
|
||||
- "\U000F0D43" # air-filter
|
||||
- "\U000F1099" # air-humidifier
|
||||
- "\U000F1466" # air-humidifier-off
|
||||
- "\U000F0028" # alert-circle
|
||||
- "\U000F0937" # angle-acute
|
||||
- "\U000F0042" # arrow-bottom-left
|
||||
- "\U000F0043" # arrow-bottom-right
|
||||
- "\U000F06C0" # arrow-down-box
|
||||
- "\U000F0E73" # arrow-left-right
|
||||
- "\U000F0054" # arrow-right
|
||||
- "\U000F093C" # arrow-split-vertical
|
||||
- "\U000F005B" # arrow-top-left
|
||||
- "\U000F005C" # arrow-top-right
|
||||
- "\U000F06C3" # arrow-up-box
|
||||
- "\U000F093D" # audio-video
|
||||
- "\U000F11B6" # audio-video-off
|
||||
- "\U000F0079" # battery
|
||||
- "\U000F007A" # battery-10
|
||||
- "\U000F007B" # battery-20
|
||||
- "\U000F007C" # battery-30
|
||||
- "\U000F007D" # battery-40
|
||||
- "\U000F007E" # battery-50
|
||||
- "\U000F007F" # battery-60
|
||||
- "\U000F0080" # battery-70
|
||||
- "\U000F0081" # battery-80
|
||||
- "\U000F0082" # battery-90
|
||||
- "\U000F0083" # battery-alert
|
||||
- "\U000F0084" # battery-charging
|
||||
- "\U000F008E" # battery-outline
|
||||
- "\U000F0091" # battery-unknown
|
||||
- "\U000F1A2B" # blinds-horizontal
|
||||
- "\U000F1A2C" # blinds-horizontal-closed
|
||||
- "\U000F00AF" # bluetooth
|
||||
- "\U000F00B1" # bluetooth-connect
|
||||
- "\U000F00DE" # brightness-5
|
||||
- "\U000F00E0" # brightness-7
|
||||
- "\U000F1B50" # button-pointer
|
||||
- "\U000F010C" # car-battery
|
||||
- "\U000F1019" # car-coolant-level
|
||||
- "\U000F0114" # cash
|
||||
- "\U000F0119" # cast-connected
|
||||
- "\U000F078A" # cast-off
|
||||
- "\U000F05E0" # check-circle
|
||||
- "\U000F05E1" # check-circle-outline
|
||||
- "\U000F0C54" # check-network-outline
|
||||
- "\U000F0133" # checkbox-marked-circle
|
||||
- "\U000F0AA5" # circle-slice-8
|
||||
- "\U000F0954" # clock
|
||||
- "\U000F0155" # clock-start
|
||||
- "\U000F015A" # close-circle-outline
|
||||
- "\U000F0C5F" # close-network-outline
|
||||
- "\U000F1382" # compass-rose
|
||||
- "\U000F01A1" # crop-portrait
|
||||
- "\U000F1136" # crosshairs-question
|
||||
- "\U000F1480" # current-ac
|
||||
- "\U000F01BC" # database
|
||||
- "\U000F07C5" # ear-hearing
|
||||
- "\U000F0208" # eye
|
||||
- "\U000F0D04" # eye-check
|
||||
- "\U000F081D" # fan-off
|
||||
- "\U000F0279" # format-list-bulleted
|
||||
- "\U000F06DA" # garage-open
|
||||
- "\U000F0299" # gate
|
||||
- "\U000F116A" # gate-open
|
||||
- "\U000F06A1" # home-outline
|
||||
- "\U000F0318" # lan-connect
|
||||
- "\U000F0319" # lan-disconnect
|
||||
- "\U000F140B" # lightning-bolt
|
||||
- "\U000F08EE" # lock-alert
|
||||
- "\U000F097F" # lock-clock
|
||||
- "\U000F033F" # lock-open
|
||||
- "\U000F0BAC" # molecule
|
||||
- "\U000F12FE" # molecule-co
|
||||
- "\U000F1435" # motion-sensor-off
|
||||
- "\U000F038A" # music-note-off
|
||||
- "\U000F03C3" # octagon
|
||||
- "\U000F03D3" # package
|
||||
- "\U000F03D5" # package-up
|
||||
- "\U000F17C5" # ph
|
||||
- "\U000F184D" # pipe-valve
|
||||
- "\U000F06A6" # power-plug-off
|
||||
- "\U000F0996" # progress-clock
|
||||
- "\U000F1A23" # projector-off
|
||||
- "\U000F043C" # radioactive
|
||||
- "\U000F0445" # ray-vertex
|
||||
- "\U000F1A6B" # roller-shade
|
||||
- "\U000F1727" # script-text-play
|
||||
- "\U000F0483" # security
|
||||
- "\U000F0498" # shield
|
||||
- "\U000F06BB" # shield-airplane
|
||||
- "\U000F099D" # shield-lock
|
||||
- "\U000F1828" # shield-moon
|
||||
- "\U000F099E" # shield-off
|
||||
- "\U000F0499" # shield-outline
|
||||
- "\U000F095B" # sine-wave
|
||||
- "\U000F192E" # smoke-detector-alert
|
||||
- "\U000F180B" # smoke-detector-variant
|
||||
- "\U000F1930" # smoke-detector-variant-alert
|
||||
- "\U000F04C4" # speaker-off
|
||||
- "\U000F1B73" # speaker-pause
|
||||
- "\U000F1B72" # speaker-play
|
||||
- "\U000F04C5" # speedometer
|
||||
- "\U000F1429" # spoon-sugar
|
||||
- "\U000F0E67" # sprout-outline
|
||||
- "\U000F0764" # square
|
||||
- "\U000F0763" # square-outline
|
||||
- "\U000F1A75" # storage-tank
|
||||
- "\U000F17FE" # sun-wireless
|
||||
- "\U000F083B" # television-off
|
||||
- "\U000F0F89" # television-pause
|
||||
- "\U000F0ECF" # television-play
|
||||
- "\U000F0FE6" # texture-box
|
||||
- "\U000F0510" # thermometer-lines
|
||||
- "\U000F1A25" # toggle-switch-variant
|
||||
- "\U000F1A26" # toggle-switch-variant-off
|
||||
- "\U000F0D3E" # transmission-tower
|
||||
- "\U000F0566" # vibrate
|
||||
- "\U000F11B4" # water-boiler-off
|
||||
- "\U000F058D" # water-off
|
||||
- "\U000F1855" # water-opacity
|
||||
- "\U000F05A1" # weight
|
||||
- "\U000F05A9" # wifi
|
||||
- "\U000F05AE" # window-closed
|
||||
- "\U000F05B1" # window-open
|
||||
|
||||
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
|
||||
id: materialdesign_icons_mini
|
||||
|
||||
+126
-1
@@ -3,7 +3,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.57"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.58"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -1078,6 +1078,131 @@ font:
|
||||
- "\U000F02FD" # information-outline
|
||||
- "\U000F0709" # restart
|
||||
- "\U000F0E4F" # lightbulb-off
|
||||
- "\U000F0B53" # account-arrow-right
|
||||
- "\U000F0D43" # air-filter
|
||||
- "\U000F1099" # air-humidifier
|
||||
- "\U000F1466" # air-humidifier-off
|
||||
- "\U000F0028" # alert-circle
|
||||
- "\U000F0937" # angle-acute
|
||||
- "\U000F0042" # arrow-bottom-left
|
||||
- "\U000F0043" # arrow-bottom-right
|
||||
- "\U000F06C0" # arrow-down-box
|
||||
- "\U000F0E73" # arrow-left-right
|
||||
- "\U000F0054" # arrow-right
|
||||
- "\U000F093C" # arrow-split-vertical
|
||||
- "\U000F005B" # arrow-top-left
|
||||
- "\U000F005C" # arrow-top-right
|
||||
- "\U000F06C3" # arrow-up-box
|
||||
- "\U000F093D" # audio-video
|
||||
- "\U000F11B6" # audio-video-off
|
||||
- "\U000F0079" # battery
|
||||
- "\U000F007A" # battery-10
|
||||
- "\U000F007B" # battery-20
|
||||
- "\U000F007C" # battery-30
|
||||
- "\U000F007D" # battery-40
|
||||
- "\U000F007E" # battery-50
|
||||
- "\U000F007F" # battery-60
|
||||
- "\U000F0080" # battery-70
|
||||
- "\U000F0081" # battery-80
|
||||
- "\U000F0082" # battery-90
|
||||
- "\U000F0083" # battery-alert
|
||||
- "\U000F0084" # battery-charging
|
||||
- "\U000F008E" # battery-outline
|
||||
- "\U000F0091" # battery-unknown
|
||||
- "\U000F1A2B" # blinds-horizontal
|
||||
- "\U000F1A2C" # blinds-horizontal-closed
|
||||
- "\U000F00AF" # bluetooth
|
||||
- "\U000F00B1" # bluetooth-connect
|
||||
- "\U000F00DE" # brightness-5
|
||||
- "\U000F00E0" # brightness-7
|
||||
- "\U000F1B50" # button-pointer
|
||||
- "\U000F010C" # car-battery
|
||||
- "\U000F1019" # car-coolant-level
|
||||
- "\U000F0114" # cash
|
||||
- "\U000F0119" # cast-connected
|
||||
- "\U000F078A" # cast-off
|
||||
- "\U000F05E0" # check-circle
|
||||
- "\U000F05E1" # check-circle-outline
|
||||
- "\U000F0C54" # check-network-outline
|
||||
- "\U000F0133" # checkbox-marked-circle
|
||||
- "\U000F0AA5" # circle-slice-8
|
||||
- "\U000F0954" # clock
|
||||
- "\U000F0155" # clock-start
|
||||
- "\U000F015A" # close-circle-outline
|
||||
- "\U000F0C5F" # close-network-outline
|
||||
- "\U000F1382" # compass-rose
|
||||
- "\U000F01A1" # crop-portrait
|
||||
- "\U000F1136" # crosshairs-question
|
||||
- "\U000F1480" # current-ac
|
||||
- "\U000F01BC" # database
|
||||
- "\U000F07C5" # ear-hearing
|
||||
- "\U000F0208" # eye
|
||||
- "\U000F0D04" # eye-check
|
||||
- "\U000F081D" # fan-off
|
||||
- "\U000F0279" # format-list-bulleted
|
||||
- "\U000F06DA" # garage-open
|
||||
- "\U000F0299" # gate
|
||||
- "\U000F116A" # gate-open
|
||||
- "\U000F06A1" # home-outline
|
||||
- "\U000F0318" # lan-connect
|
||||
- "\U000F0319" # lan-disconnect
|
||||
- "\U000F140B" # lightning-bolt
|
||||
- "\U000F08EE" # lock-alert
|
||||
- "\U000F097F" # lock-clock
|
||||
- "\U000F033F" # lock-open
|
||||
- "\U000F0BAC" # molecule
|
||||
- "\U000F12FE" # molecule-co
|
||||
- "\U000F1435" # motion-sensor-off
|
||||
- "\U000F038A" # music-note-off
|
||||
- "\U000F03C3" # octagon
|
||||
- "\U000F03D3" # package
|
||||
- "\U000F03D5" # package-up
|
||||
- "\U000F17C5" # ph
|
||||
- "\U000F184D" # pipe-valve
|
||||
- "\U000F06A6" # power-plug-off
|
||||
- "\U000F0996" # progress-clock
|
||||
- "\U000F1A23" # projector-off
|
||||
- "\U000F043C" # radioactive
|
||||
- "\U000F0445" # ray-vertex
|
||||
- "\U000F1A6B" # roller-shade
|
||||
- "\U000F1727" # script-text-play
|
||||
- "\U000F0483" # security
|
||||
- "\U000F0498" # shield
|
||||
- "\U000F06BB" # shield-airplane
|
||||
- "\U000F099D" # shield-lock
|
||||
- "\U000F1828" # shield-moon
|
||||
- "\U000F099E" # shield-off
|
||||
- "\U000F0499" # shield-outline
|
||||
- "\U000F095B" # sine-wave
|
||||
- "\U000F192E" # smoke-detector-alert
|
||||
- "\U000F180B" # smoke-detector-variant
|
||||
- "\U000F1930" # smoke-detector-variant-alert
|
||||
- "\U000F04C4" # speaker-off
|
||||
- "\U000F1B73" # speaker-pause
|
||||
- "\U000F1B72" # speaker-play
|
||||
- "\U000F04C5" # speedometer
|
||||
- "\U000F1429" # spoon-sugar
|
||||
- "\U000F0E67" # sprout-outline
|
||||
- "\U000F0764" # square
|
||||
- "\U000F0763" # square-outline
|
||||
- "\U000F1A75" # storage-tank
|
||||
- "\U000F17FE" # sun-wireless
|
||||
- "\U000F083B" # television-off
|
||||
- "\U000F0F89" # television-pause
|
||||
- "\U000F0ECF" # television-play
|
||||
- "\U000F0FE6" # texture-box
|
||||
- "\U000F0510" # thermometer-lines
|
||||
- "\U000F1A25" # toggle-switch-variant
|
||||
- "\U000F1A26" # toggle-switch-variant-off
|
||||
- "\U000F0D3E" # transmission-tower
|
||||
- "\U000F0566" # vibrate
|
||||
- "\U000F11B4" # water-boiler-off
|
||||
- "\U000F058D" # water-off
|
||||
- "\U000F1855" # water-opacity
|
||||
- "\U000F05A1" # weight
|
||||
- "\U000F05A9" # wifi
|
||||
- "\U000F05AE" # window-closed
|
||||
- "\U000F05B1" # window-open
|
||||
|
||||
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
|
||||
id: materialdesign_icons_mini
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
## 0.2.67 (firmware 0.2.58)
|
||||
|
||||
Tiles take what they can do, their words and their icons from Home Assistant, and a tap can run any action Home Assistant offers.
|
||||
|
||||
- **On / off for blinds, curtains and garage doors.** Under **On tap**, a cover now has **On / off**: a tap opens or closes it, and stops it while it moves, with Home Assistant's own `cover.toggle`. Holding the tile still opens its card. The same goes for everything else Home Assistant can toggle, such as a script, not only for lights, switches, fans, speakers and climate.
|
||||
- **Perform action.** Under **On tap**, choose **Perform action** and pick one of the actions Home Assistant has for that entity, under Home Assistant's own names: for a blind that is Open cover, Close cover, Stop cover, Toggle cover, Set cover position and the tilt actions, plus what its integration adds, such as Snapshot for a Sonos. A chosen action shows its fields as Home Assistant describes them: a number with its unit, On or Off, a choice from a list, or text. A field you leave empty is not sent, a field Home Assistant needs is marked until you fill it in, and only fields that fit the device show. Holding the tile still opens its card.
|
||||
- **The choices come from Home Assistant.** ESP Screens asks Home Assistant which actions each entity has, instead of keeping a list of its own. The tile settings only offer what works: no On / off for a speaker that can't turn on and off, no small slider for a lamp that only switches, no position slider for a cover without a position, no graph for a sensor without numbers, and no forecast for a weather service without daily forecasts. An integration or device that adds an action shows it right away, without an update of ESP Screens.
|
||||
- **Nothing you set changes by itself.** A tile keeps every setting it has. When Home Assistant can't do one, such as On / off on a Sonos, the tile settings show a warning and what to choose instead. A new setting Home Assistant doesn't support is refused when you save, also when Claude in Home Assistant asks for it.
|
||||
- **Refused shows on the tile.** When Home Assistant refuses a tap's action, the tile says Refused for a few seconds instead of waiting with a spinner. This goes for Perform action, On / off and the other taps.
|
||||
- **Numbers as Home Assistant shows them.** A sensor with a display precision in Home Assistant shows that many decimals on its tile and card: 21.456 °C becomes 21.5 °C when Home Assistant shows one decimal. A sensor without one keeps its value as it comes. Rounded values also mean fewer updates for the screen when only the hidden decimals change.
|
||||
- **Home Assistant's words instead of raw states.** Where a tile or card showed a state as Home Assistant stores it, it now shows Home Assistant's own word: a blind says Open or Closing instead of open or closing, a speaker Playing, a robot Returning to dock, a washing machine Rinsing, and a select of an integration its option's name, such as Vacuum and mop. The top bar and history timelines do the same, the vacuum card's chips name a setting the screen has no short name for as the integration does (Off (raised brush)), and the editor's inspector says Heat/Cool instead of heat_cool. The words come from Home Assistant, so a new device brings its own. The screen's own words stay where it had them: On and Off, Docked, a door's Open and Closed, the person's Home and Away, the chips' Vac & mop and Normal.
|
||||
- **Home Assistant's icons.** A tile without an icon of its own now shows the icon Home Assistant shows for it: a blind open or closed, a speaker that plays, a door that is open, a battery at its level, a thermostat for climate, a palette for a scene. The icon follows the state, as in Home Assistant, and the tile, the top bar and the editor show the same one. Where the screen lacks Home Assistant's icon, such as an integration's own or one a later Home Assistant adds, it keeps its own icon. An icon you chose stays.
|
||||
- **A double-width tile gets a control that works.** When its usual control isn't there, such as open, stop and close on a skylight that only has a position, it takes the first one Home Assistant offers.
|
||||
- **A scene or button that never ran works right away.** Home Assistant shows a scene, button or input button that was never used as unknown, and still lets you press it. The tile said Unavailable and did nothing; it now says Never run and runs when you tap it.
|
||||
- **The entity list only shows what can be a tile.** Locks, trackers, counters and the other entities for the top bar no longer show under All or in a search, where adding one failed when saving. Input buttons have their own badge and show under Actions.
|
||||
- **A click on the text under a setting no longer changes it.** Clicking the explanation under Direct control pressed the first choice above it.
|
||||
- **Claude in Home Assistant can set it.** A tile event takes `action` and `data`, such as `action: cover.set_cover_position` with `data: {position: 50}`, and says what's missing when Home Assistant wouldn't take it. The skill also says what On / off does. Install it again from Settings → Claude to get it.
|
||||
- Needs firmware 0.2.58 for On / off on covers and other entities that are new to it, Perform action, Refused on the tile, a scene or button that never ran, and the words and new icons on tiles: press **Update** on the screen. Until then a tap opens the card or taps as Automatic, as before. Everything else works right after updating the app, including the rounding and the words in the top bar, timelines, chips and inspector.
|
||||
|
||||
## 0.2.66 (firmware 0.2.57)
|
||||
|
||||
Camera images on the Guition: see who is at the door.
|
||||
|
||||
@@ -40,7 +40,7 @@ def text():
|
||||
colors = ', '.join(f"`{name}` ({item['label']})" for name, item in TILE_BACKGROUNDS.items() if item['color'])
|
||||
suggested = ', '.join(f'`{name}`' for name in ALERT_SUGGESTED_ICONS)
|
||||
groups = '\n'.join(f'- {group}: ' + ', '.join(f'`{name}` ({label})' for name, _, label in icons) for group, icons in tile_icons.GROUPS)
|
||||
fixed = ', '.join(f'`{name}`' for name, _ in tile_icons.FIXED)
|
||||
fixed = ', '.join(f'`{name}`' for name, _ in tile_icons.FIXED + tile_icons.HA_DEFAULTS)
|
||||
endings = ', '.join(f'`{action}` ({label[0].lower() + label[1:]})' for action, label in ALERT_ENDINGS)
|
||||
controls = '\n'.join(f'| `{domain}` | ' + ', '.join(f'`{key}` ({label.lower()})' for key, label in choices) + ' |'
|
||||
for domain, choices in CONTROLS.items())
|
||||
@@ -94,7 +94,8 @@ actions:
|
||||
| `controls` | What you can operate on the tile itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). |
|
||||
| `icon`, `color` | An icon from the list further down, and one of the pastel colors. |
|
||||
| `tap` | What a tap does: `auto`, `detail` (open the card), `toggle` or `none`. |
|
||||
| `tap` | What a tap does: `auto`, `detail` (open the card), `toggle`, `action` or `none`. `toggle` works for anything Home Assistant can toggle for that entity, such as a light, a cover (open, close, or stop while it moves) or a speaker that turns on and off. Holding the tile still opens its card. |
|
||||
| `action`, `data` | Perform action: an action Home Assistant offers for the tile's own entity, such as `cover.set_cover_position`, with `data` for its fields (`position: 50`). Giving `action` sets `tap` to `action`. The target is always the tile's entity. Only actions and fields Home Assistant lists for that entity are accepted; the answer says what is missing. |
|
||||
| `entities` | Only for `esp_screens_order_tiles`: the entities in the order you want them. |
|
||||
|
||||
A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that.
|
||||
|
||||
+142
-16
@@ -20,7 +20,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.57'
|
||||
FIRMWARE_VERSION = '0.2.58'
|
||||
# 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.
|
||||
@@ -457,6 +457,11 @@ def tile_options(data, current=None):
|
||||
options[name] = TILE_SIZES.get(loose(value), str(value))
|
||||
else:
|
||||
options[name] = str(value).strip()
|
||||
# Perform action (app 0.2.67): `action` names Home Assistant's action and `data` its fields; the tap follows.
|
||||
if data.get('action') not in (None, ''):
|
||||
options['action'] = {'action': str(data['action']).strip(), **({'data': data['data']} if isinstance(data.get('data'), dict) and data['data'] else {})}
|
||||
if data.get('tap') in (None, ''):
|
||||
options['tap'] = 'action'
|
||||
if options.get('controls', 'none') != 'none' or options.get('display') in WIDE_ONLY:
|
||||
options['size'] = 'wide'
|
||||
return {key: value for key, value in options.items() if value not in (None, '')}
|
||||
@@ -588,11 +593,61 @@ def layout_snapshot(screen, layout):
|
||||
'page': slot // SLOTS_PER_PAGE + 1, 'row': slot % SLOTS_PER_PAGE // 2 + 1,
|
||||
'column': 'right' if slot % 2 else 'left', 'slot': slot,
|
||||
'size': options.get('size', 'single'), 'controls': options.get('controls', ''),
|
||||
'display': options.get('display', 'standard')})
|
||||
'display': options.get('display', 'standard'), 'tap': options.get('tap', 'auto'),
|
||||
**({'action': options['action']} if options.get('tap') == 'action' and 'action' in options else {})})
|
||||
return {'screen': screen.get('name', ''), 'node': screen.get('node') or '', 'title': layout.get('title', ''),
|
||||
'pages': max([tile['page'] for tile in tiles], default=1), 'tiles': tiles}
|
||||
|
||||
def validate_layout(data):
|
||||
# A tap's own action (app 0.2.67): Home Assistant's `domain.action` with data for its fields. It always acts on the tile's
|
||||
# entity, so the keys that name a target stay out of the data. Text travels as data and every other value as a template
|
||||
# Home Assistant renders back into that value (ESPHome's action data is text only); both stay small for the CYD.
|
||||
ACTION_NAME = re.compile(r'[a-z0-9_]+\.[a-z0-9_]+')
|
||||
ACTION_FIELD = re.compile(r'[a-z0-9_]{1,32}')
|
||||
TARGET_KEYS = frozenset(('entity_id', 'device_id', 'area_id', 'floor_id', 'label_id'))
|
||||
ACTION_MAX_FIELDS = 8
|
||||
ACTION_MAX_VALUE = 400
|
||||
ACTION_MAX_BYTES = 800
|
||||
|
||||
def action_for_screen(value):
|
||||
"""A tap's own action as the firmware sends it (0.2.58+): {"s": action, "d": [[key, text]], "t": [[key, template]]}."""
|
||||
pairs, templates = [], []
|
||||
for key, item in (value.get('data') or {}).items():
|
||||
if isinstance(item, str):
|
||||
pairs.append([key, item])
|
||||
else:
|
||||
templates.append([key, '{{ %s | from_json }}' % json.dumps(json.dumps(item, ensure_ascii=False, separators=(',', ':'), allow_nan=False), ensure_ascii=False)])
|
||||
act = {'s': value['action']}
|
||||
if pairs:
|
||||
act['d'] = pairs
|
||||
if templates:
|
||||
act['t'] = templates
|
||||
return act
|
||||
|
||||
def validate_tap_action(value):
|
||||
"""The stored form of a tap's own action; ValueError with what to change."""
|
||||
if not isinstance(value, dict) or set(value) - {'action', 'data'}:
|
||||
raise ValueError('Choose an action from the list.')
|
||||
name, data = value.get('action'), value.get('data', {})
|
||||
if not isinstance(name, str) or len(name) > 64 or not ACTION_NAME.fullmatch(name):
|
||||
raise ValueError('Choose an action from the list.')
|
||||
if not isinstance(data, dict) or len(data) > ACTION_MAX_FIELDS:
|
||||
raise ValueError(f'An action on a tap sets at most {ACTION_MAX_FIELDS} fields.')
|
||||
for key in data:
|
||||
if not isinstance(key, str) or not ACTION_FIELD.fullmatch(key) or key in TARGET_KEYS:
|
||||
raise ValueError(f'{key} is not a field an action on a tap can set.')
|
||||
clean = {'action': name, **({'data': dict(data)} if data else {})}
|
||||
try:
|
||||
act = action_for_screen(clean)
|
||||
size = len(json.dumps(act, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('A value of the action is something the screen cannot send.') from None
|
||||
if size > ACTION_MAX_BYTES or any(len(item[1].encode()) > ACTION_MAX_VALUE for item in act.get('d', []) + act.get('t', [])):
|
||||
raise ValueError('The values of the action are too long for the screen.')
|
||||
return clean
|
||||
|
||||
def validate_layout(data, stored=False):
|
||||
"""A layout as the editor, a tile event or the storage gives it. `stored`: loaded from the app's own data, where a
|
||||
tile setting this version doesn't know (saved by a newer app) stays as it is instead of stopping the app."""
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Invalid layout.')
|
||||
title, tiles = data.get('title'), data.get('tiles')
|
||||
@@ -611,9 +666,17 @@ def validate_layout(data):
|
||||
raise ValueError('A tile name may contain at most 80 bytes.')
|
||||
seen.add(tile['entity'])
|
||||
item = {'entity': tile['entity'], 'name': name.strip()}
|
||||
if stored and 'options' in tile:
|
||||
try:
|
||||
validate_layout({'title': 'stored', 'tiles': [{'entity': tile['entity'], 'options': tile['options']}]})
|
||||
except ValueError:
|
||||
# The screen ignores what its firmware doesn't know either.
|
||||
item['options'] = dict(tile['options']) if isinstance(tile['options'], dict) else {}
|
||||
clean.append(item)
|
||||
continue
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}:
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}:
|
||||
raise ValueError('Unknown tile settings.')
|
||||
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
|
||||
raise ValueError('Choose a pastel background color from the palette.')
|
||||
@@ -621,15 +684,24 @@ def validate_layout(data):
|
||||
raise ValueError('Choose an icon from the list.')
|
||||
domain = tile['entity'].split('.')[0]
|
||||
displays = DISPLAYS.get(domain, ('standard', 'watch'))
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none', 'action'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
|
||||
for key, allowed in choices.items():
|
||||
if key in options and options[key] not in allowed:
|
||||
raise ValueError('Invalid tile setting: ' + key)
|
||||
# The five-day strip and the sun path only fit a double-width card.
|
||||
if options.get('display') in WIDE_ONLY:
|
||||
options = {**options, 'size': 'wide'}
|
||||
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}:
|
||||
# On / off sends <domain>.toggle. Whether Home Assistant offers that for the entity is checked when saving
|
||||
# (Manager.check_supported, app 0.2.67); the built-in cards have nothing to switch.
|
||||
if options.get('tap') == 'toggle' and domain == 'screen':
|
||||
raise ValueError("This entity doesn't support an on/off action.")
|
||||
# Perform action (app 0.2.67) keeps its action; another tap choice leaves a stale one behind.
|
||||
if options.get('tap') == 'action':
|
||||
if domain == 'screen':
|
||||
raise ValueError("A built-in card can't perform an action.")
|
||||
options = {**options, 'action': validate_tap_action(options.get('action'))}
|
||||
elif 'action' in options:
|
||||
options = {key: value for key, value in options.items() if key != 'action'}
|
||||
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
|
||||
raise ValueError("This entity doesn't support a mini-slider.")
|
||||
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
|
||||
@@ -891,24 +963,77 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
return result or None
|
||||
return None
|
||||
|
||||
def tile_icon(tile, attrs):
|
||||
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon; None keeps the firmware default."""
|
||||
def tile_icon(tile, attrs, state=None, entry=None):
|
||||
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon, else the icon Home Assistant's frontend shows
|
||||
for this state (app 0.2.67); None keeps the firmware default."""
|
||||
choice = tile.get('options', {}).get('icon', 'auto')
|
||||
return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
|
||||
if choice in tile_icons.ICONS:
|
||||
return tile_icons.ICONS[choice][0]
|
||||
return tile_icons.ha_icon(attrs) or tile_icons.default_glyph(tile['entity'], state, attrs, entry)
|
||||
|
||||
def screen_options(tile, attrs):
|
||||
def screen_options(tile, attrs, state=None, entry=None):
|
||||
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)
|
||||
and `controls` only as the set the card really shows (firmware 0.2.19+, ignored before)."""
|
||||
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls')}
|
||||
icon = tile_icon(tile, attrs)
|
||||
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls', 'action')}
|
||||
icon = tile_icon(tile, attrs, state, entry)
|
||||
if icon:
|
||||
options['icon'] = icon
|
||||
controls = resolve_controls(tile)
|
||||
if controls:
|
||||
options['controls'] = controls
|
||||
# Perform action travels in the firmware's compact form (0.2.58+); older firmware taps automatically.
|
||||
action = tile.get('options', {}).get('action')
|
||||
if options.get('tap') == 'action' and isinstance(action, dict):
|
||||
try:
|
||||
options['act'] = action_for_screen(validate_tap_action(action))
|
||||
except ValueError:
|
||||
pass
|
||||
return options if options or 'options' in tile else None
|
||||
|
||||
def state_message(index, tile, states, extra=None):
|
||||
def rounded_state(value, precision):
|
||||
"""A sensor's state as Home Assistant shows it with a display precision (app 0.2.67): "21.456" at 1 becomes "21.5".
|
||||
No thousands separators, so the screen still reads the number; anything that isn't a number stays as it is."""
|
||||
if type(precision) is not int or not 0 <= precision <= 6:
|
||||
return value
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
text = format(Decimal(str(value)).quantize(Decimal(1).scaleb(-precision), rounding=ROUND_HALF_UP), 'f')
|
||||
return text[1:] if text.startswith('-') and not text.strip('-0.') else text
|
||||
|
||||
def ha_word(entity_id, suffix, attributes, entry, words):
|
||||
"""Home Assistant's English word for `suffix` (`state.<state>`, or `state_attributes.<attribute>.state.<value>`) as its
|
||||
frontend picks it: the integration's word for the entity's translation key, then the domain's word for the state's
|
||||
device class, then the domain's. None when Home Assistant has none (frontend/get_translations `entity` and
|
||||
`entity_component`, app 0.2.67)."""
|
||||
if not isinstance(words, dict) or not words or not isinstance(entity_id, str):
|
||||
return None
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
device_class = attributes.get('device_class') if isinstance(attributes, dict) else None
|
||||
keys = []
|
||||
if entry.get('platform') and entry.get('translation_key'):
|
||||
keys.append(f"component.{entry['platform']}.entity.{domain}.{entry['translation_key']}.{suffix}")
|
||||
if isinstance(device_class, str) and device_class:
|
||||
keys.append(f'component.{domain}.entity_component.{device_class}.{suffix}')
|
||||
keys.append(f'component.{domain}.entity_component._.{suffix}')
|
||||
return next((words[key] for key in keys if isinstance(words.get(key), str) and words[key]), None)
|
||||
|
||||
def state_word(entity_id, state, attributes, entry, words):
|
||||
"""Home Assistant's word for a state ("rinsing" is "Rinsing", "heat_cool" is "Heat/Cool"), or None."""
|
||||
return ha_word(entity_id, f'state.{state}', attributes, entry, words) if isinstance(state, str) and state else None
|
||||
|
||||
def attribute_word(entity_id, attribute, value, attributes, entry, words):
|
||||
"""Home Assistant's word for an attribute's value, such as a robot's suction level, or None."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
return ha_word(entity_id, f'state_attributes.{attribute}.state.{value}', attributes, entry, words)
|
||||
|
||||
def state_message(index, tile, states, extra=None, precision=None, entry=None):
|
||||
if tile['entity'] in BUILTIN:
|
||||
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
|
||||
@@ -937,10 +1062,10 @@ def state_message(index, tile, states, extra=None):
|
||||
limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8
|
||||
bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit]
|
||||
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
|
||||
options = screen_options(tile, attrs)
|
||||
options = screen_options(tile, attrs, state.get('state'), entry)
|
||||
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
|
||||
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
|
||||
'state': short(rounded_state(state.get('state', 'unavailable'), precision) if tile['entity'].startswith('sensor.') else state.get('state', 'unavailable'), 160), 'a': bounded,
|
||||
**({'o': options} if options is not None else {}), **({'x': extra} if extra else {})}
|
||||
|
||||
def encode(message):
|
||||
@@ -1150,7 +1275,8 @@ def discover(registry, states, devices, areas):
|
||||
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
|
||||
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid,
|
||||
'device': device.get('name_by_user') or device.get('name') or '', 'area': area,
|
||||
'state': state.get('state', 'unavailable'), 'icon': tile_icons.ha_icon(state.get('attributes')),
|
||||
'state': state.get('state', 'unavailable'),
|
||||
'icon': tile_icons.ha_icon(state.get('attributes')) or tile_icons.default_glyph(eid, state.get('state'), state.get('attributes'), item),
|
||||
# Top-bar-only domains (a phone's tracker, a lock) stay out of the tile picker.
|
||||
**({} if tile else {'tile': False})})
|
||||
# YAML entities may not have an entity-registry entry.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""What Home Assistant says an entity can do (app 0.2.67).
|
||||
|
||||
Home Assistant describes each action with the entities it works on: its `target`, filtered by domain, integration,
|
||||
device class and supported features. Since 2025.12 it also answers `get_services_for_target` with the actions that fit
|
||||
one entity. ESP Screens asks it instead of keeping lists of its own, so a device or an integration that brings an action
|
||||
works without an update: a cover gets On / off because Home Assistant lists `cover.toggle` for it, a speaker that cannot
|
||||
turn on and off doesn't. `local_actions` gives the same answer from the action descriptions for an older Home Assistant.
|
||||
|
||||
What stays ours is which action each of our widgets sends (INLINE, CONTROLS): a small slider on a light is `light.turn_on` with a
|
||||
brightness, so it only fits a light for which Home Assistant offers that field.
|
||||
"""
|
||||
import core
|
||||
from core import attribute_word, state_word
|
||||
import header_bar
|
||||
import history_card
|
||||
|
||||
# Screen widgets and the Home Assistant action behind them: (action, field or None). A widget fits an entity when Home
|
||||
# Assistant lists one of its actions for it, and, where a field is named, offers that field for the entity.
|
||||
TOGGLE = '{domain}.toggle'
|
||||
INLINE = {
|
||||
'light': (('light.turn_on', 'brightness_pct'),),
|
||||
'fan': (('fan.set_percentage', None),),
|
||||
'cover': (('cover.set_cover_position', None),),
|
||||
'media_player': (('media_player.volume_set', None),),
|
||||
'number': (('number.set_value', None),),
|
||||
'input_number': (('input_number.set_value', None),),
|
||||
}
|
||||
CONTROLS = {
|
||||
'climate': {'setpoint': (('climate.set_temperature', None),), 'mode': (('climate.set_hvac_mode', None),)},
|
||||
'switch': {'toggle': (('switch.toggle', None),)},
|
||||
'input_boolean': {'toggle': (('input_boolean.toggle', None),)},
|
||||
'light': {'toggle': (('light.toggle', None),), 'brightness': INLINE['light']},
|
||||
'fan': {'toggle': (('fan.toggle', None),), 'speed': INLINE['fan']},
|
||||
'vacuum': {'buttons': tuple((action, None) for action in ('vacuum.start', 'vacuum.pause', 'vacuum.stop', 'vacuum.return_to_base', 'vacuum.turn_on'))},
|
||||
'cover': {'buttons': tuple((action, None) for action in ('cover.open_cover', 'cover.close_cover', 'cover.stop_cover')),
|
||||
'position': INLINE['cover']},
|
||||
'media_player': {'volume': (('media_player.volume_set', None), ('media_player.volume_mute', None)),
|
||||
'playback': tuple((action, None) for action in ('media_player.media_play_pause', 'media_player.media_play',
|
||||
'media_player.media_pause', 'media_player.media_next_track',
|
||||
'media_player.media_previous_track'))},
|
||||
'number': {'stepper': INLINE['number'], 'slider': INLINE['number']},
|
||||
'input_number': {'stepper': INLINE['input_number'], 'slider': INLINE['input_number']},
|
||||
'select': {'stepper': (('select.select_option', None),)},
|
||||
'input_select': {'stepper': (('input_select.select_option', None),)},
|
||||
'timer': {'buttons': (('timer.start', None), ('timer.cancel', None))},
|
||||
'scene': {'run': (('scene.turn_on', None),)},
|
||||
'script': {'run': (('script.turn_on', None),)},
|
||||
'button': {'run': (('button.press', None),)},
|
||||
'input_button': {'run': (('input_button.press', None),)},
|
||||
}
|
||||
# Weather forecast bits (WeatherEntityFeature): the forecast card draws days.
|
||||
WEATHER_DAILY = 1
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
return list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
|
||||
|
||||
def _features(attributes):
|
||||
value = (attributes or {}).get('supported_features')
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
||||
|
||||
|
||||
def entity_filter_matches(entity_filter, entity_id, attributes, platform):
|
||||
"""One `target.entity` filter of an action description, as Home Assistant applies it to an entity: every named
|
||||
condition must hold. A `supported_features` entry is a mask the entity must support completely, and any entry will
|
||||
do (the action's own required features)."""
|
||||
if not isinstance(entity_filter, dict):
|
||||
return False
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
domains = _as_list(entity_filter.get('domain'))
|
||||
if domains and domain not in domains:
|
||||
return False
|
||||
integration = entity_filter.get('integration')
|
||||
if integration and integration != platform:
|
||||
return False
|
||||
classes = _as_list(entity_filter.get('device_class'))
|
||||
if classes and (attributes or {}).get('device_class') not in classes:
|
||||
return False
|
||||
masks = [mask for mask in _as_list(entity_filter.get('supported_features')) if isinstance(mask, int)]
|
||||
if masks and not any(_features(attributes) & mask == mask for mask in masks):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def target_matches(target, entity_id, attributes, platform):
|
||||
"""Whether an action works on this entity. An action without a target is not an entity action; one whose target
|
||||
names no entity filter takes every entity (homeassistant.toggle)."""
|
||||
if not isinstance(target, dict):
|
||||
return False
|
||||
filters = target.get('entity')
|
||||
if filters is None or filters == []:
|
||||
return 'entity' not in target or filters == []
|
||||
return any(entity_filter_matches(item, entity_id, attributes, platform) for item in _as_list(filters))
|
||||
|
||||
|
||||
def local_actions(services, entity_id, attributes, platform):
|
||||
"""`get_services_for_target` for one entity, from the action descriptions: for a Home Assistant before 2025.12."""
|
||||
found = []
|
||||
for domain, actions in (services or {}).items():
|
||||
if not isinstance(actions, dict):
|
||||
continue
|
||||
for name, description in actions.items():
|
||||
if isinstance(description, dict) and 'target' in description and target_matches(description['target'], entity_id, attributes, platform):
|
||||
found.append(f'{domain}.{name}')
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def find_field(description, name):
|
||||
"""A field of an action description, also inside a section (light.turn_on keeps some under `advanced_fields`)."""
|
||||
fields = (description or {}).get('fields') or {}
|
||||
if name in fields and isinstance(fields[name], dict):
|
||||
return fields[name]
|
||||
for value in fields.values():
|
||||
if isinstance(value, dict) and isinstance(value.get('fields'), dict) and name in value['fields']:
|
||||
return value['fields'][name]
|
||||
return None
|
||||
|
||||
|
||||
def field_matches(field, attributes):
|
||||
"""Whether Home Assistant offers a field for this entity: its `filter` names supported features (any one) or
|
||||
attribute values (any one); a field without a filter is always there."""
|
||||
field_filter = (field or {}).get('filter')
|
||||
if not isinstance(field_filter, dict) or not field_filter:
|
||||
return True
|
||||
features = [mask for mask in _as_list(field_filter.get('supported_features')) if isinstance(mask, int)]
|
||||
if features and any(_features(attributes) & mask for mask in features):
|
||||
return True
|
||||
for attribute, values in (field_filter.get('attribute') or {}).items():
|
||||
current = (attributes or {}).get(attribute)
|
||||
wanted = _as_list(values)
|
||||
if isinstance(current, (list, tuple)) and any(value in current for value in wanted):
|
||||
return True
|
||||
if not isinstance(current, (list, tuple)) and current in wanted:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fits(requirements, actions, attributes, services):
|
||||
for action, field in requirements:
|
||||
if action not in actions:
|
||||
continue
|
||||
if field is None:
|
||||
return True
|
||||
domain, name = action.split('.', 1)
|
||||
found = find_field(((services or {}).get(domain) or {}).get(name), field)
|
||||
if found is not None and field_matches(found, attributes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def capabilities(entity_id, actions, state, services):
|
||||
"""What the editor may offer for this entity, in Home Assistant's own terms: On / off, a small slider, which direct
|
||||
controls and which displays. `actions` are the actions Home Assistant lists for the entity."""
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
attributes = (state or {}).get('attributes') or {}
|
||||
actions = set(actions or ())
|
||||
displays = ['standard', 'watch']
|
||||
if domain == 'sensor':
|
||||
# The graph draws numbers; a status sensor (a washing machine's programme) has none.
|
||||
if history_card.kind(entity_id, state) == 'line':
|
||||
displays.append('graph')
|
||||
elif domain == 'weather':
|
||||
features = attributes.get('supported_features')
|
||||
# An entity that reports no features yet (unavailable) keeps the forecast, as forecast_kinds does.
|
||||
if not isinstance(features, int) or isinstance(features, bool) or features & WEATHER_DAILY:
|
||||
displays.append('forecast')
|
||||
elif domain == 'sun':
|
||||
displays.append('sunpath')
|
||||
return {
|
||||
'toggle': TOGGLE.format(domain=domain) in actions,
|
||||
'inline': domain in INLINE and _fits(INLINE[domain], actions, attributes, services),
|
||||
'controls': [key for key, requirements in CONTROLS.get(domain, {}).items() if _fits(requirements, actions, attributes, services)],
|
||||
'displays': displays,
|
||||
}
|
||||
|
||||
|
||||
def fields_for(description, attributes):
|
||||
"""The fields of an action Home Assistant offers for this entity, in its own order, sections flattened."""
|
||||
found = []
|
||||
for key, field in ((description or {}).get('fields') or {}).items():
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if isinstance(field.get('fields'), dict) and 'selector' not in field:
|
||||
found += [(inner_key, inner) for inner_key, inner in field['fields'].items() if isinstance(inner, dict) and field_matches(inner, attributes)]
|
||||
elif field_matches(field, attributes):
|
||||
found.append((key, field))
|
||||
return found
|
||||
|
||||
|
||||
def attribute_options(attributes, attribute):
|
||||
"""The values an entity offers for one of its attributes, where a `state` selector asks for one: an effect from
|
||||
effect_list, a source from source_list, a fan mode from fan_modes, a speed from supported_speeds."""
|
||||
for key in (f'{attribute}_list', f'{attribute}s', f'available_{attribute}s', f'supported_{attribute}s'):
|
||||
values = (attributes or {}).get(key)
|
||||
if isinstance(values, list) and values and all(isinstance(value, (str, int, float)) and not isinstance(value, bool) for value in values):
|
||||
return [str(value) for value in values][:60]
|
||||
return None
|
||||
|
||||
|
||||
def answers_only(description):
|
||||
"""An action that must return data (weather.get_forecasts): a tap has nowhere to show it."""
|
||||
return ((description or {}).get('response') or {}).get('optional') is False
|
||||
|
||||
|
||||
def field_choice(base, key, field, attributes, names):
|
||||
"""One field for the editor: Home Assistant's name, description and selector, and for a `state` selector the values
|
||||
this entity has for that attribute."""
|
||||
selector = field.get('selector') or {}
|
||||
found = {'key': key, 'name': names.get(f'{base}.fields.{key}.name') or field.get('name') or key,
|
||||
'description': names.get(f'{base}.fields.{key}.description') or field.get('description') or '',
|
||||
'required': bool(field.get('required')), 'selector': selector}
|
||||
if 'example' in field:
|
||||
found['example'] = field['example']
|
||||
attribute = (selector.get('state') or {}).get('attribute') if isinstance(selector.get('state'), dict) else None
|
||||
options = attribute_options(attributes, attribute) if attribute else None
|
||||
if options:
|
||||
found['options'] = options
|
||||
return found
|
||||
|
||||
|
||||
def action_choices(entity_id, actions, state, services, names, platform=None):
|
||||
"""Perform action in the editor (app 0.2.67): every action Home Assistant offers for the entity, under the names and
|
||||
descriptions Home Assistant shows (frontend/get_translations, `services`), with the fields it offers for this entity.
|
||||
The entity's own domain comes first, then its integration's actions, then Home Assistant's general ones."""
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
attributes = (state or {}).get('attributes') or {}
|
||||
names = names or {}
|
||||
found = []
|
||||
for action in sorted(actions or ()):
|
||||
action_domain, service = action.split('.', 1)
|
||||
description = ((services or {}).get(action_domain) or {}).get(service) or {}
|
||||
if answers_only(description):
|
||||
continue
|
||||
base = f'component.{action_domain}.services.{service}'
|
||||
found.append({
|
||||
'action': action,
|
||||
'name': names.get(f'{base}.name') or description.get('name') or action,
|
||||
'description': names.get(f'{base}.description') or description.get('description') or '',
|
||||
'fields': [field_choice(base, key, field, attributes, names) for key, field in fields_for(description, attributes)],
|
||||
})
|
||||
order = lambda item: (0 if item['action'].startswith(domain + '.') else 1 if platform and item['action'].startswith(platform + '.') else 2,
|
||||
item['name'].casefold())
|
||||
return sorted(found, key=order)
|
||||
|
||||
|
||||
def action_problem(entity_id, label, action, state, actions, services):
|
||||
"""Why Home Assistant wouldn't take a tap's own action for this entity, as the sentence a save gets; None when it
|
||||
would, or while Home Assistant can't say."""
|
||||
name, data = action.get('action'), action.get('data') or {}
|
||||
if actions is None:
|
||||
return None
|
||||
if name not in actions:
|
||||
return f"Home Assistant doesn't offer {name} for {label}. Choose an action from the list."
|
||||
action_domain, service = name.split('.', 1)
|
||||
description = ((services or {}).get(action_domain) or {}).get(service)
|
||||
if description is None:
|
||||
return None
|
||||
if answers_only(description):
|
||||
return f"{name} only answers with data, which a screen can't show."
|
||||
fields = dict(fields_for(description, (state or {}).get('attributes') or {}))
|
||||
for key in data:
|
||||
if key not in fields:
|
||||
return f"Home Assistant has no field {key} in {name} for {label}."
|
||||
for key, field in fields.items():
|
||||
if field.get('required') and key not in data:
|
||||
return f"{name} needs a value for {key}."
|
||||
return None
|
||||
|
||||
|
||||
def refusal(entity_id, name, key, value):
|
||||
"""The sentence a save or a tile event gets for a setting Home Assistant doesn't support for this entity."""
|
||||
label = name or entity_id
|
||||
if key == 'tap':
|
||||
return f"Home Assistant can't turn {label} on and off, so On / off would do nothing. Choose another tap action."
|
||||
if key == 'inline':
|
||||
return f"{label} has nothing a small slider can change in Home Assistant."
|
||||
if key == 'display' and value == 'graph':
|
||||
return f"{label} has no numbers to draw a graph of."
|
||||
if key == 'display' and value == 'forecast':
|
||||
return f"{label} has no daily forecast in Home Assistant."
|
||||
return f"Home Assistant doesn't offer that direct control for {label}."
|
||||
|
||||
|
||||
def unsupported(tile, previous, caps):
|
||||
"""The first setting of a tile that Home Assistant doesn't support and that isn't already saved like this, as
|
||||
(key, value), or None. Settings a screen already has stay, also when Home Assistant no longer supports them: they
|
||||
get a warning in the editor, and a save never fails on a tile nobody changed."""
|
||||
if caps is None:
|
||||
return None
|
||||
options, before = tile.get('options') or {}, (previous or {}).get('options') or {}
|
||||
checks = (
|
||||
('tap', options.get('tap') == 'toggle' and not caps.get('toggle')),
|
||||
('inline', options.get('inline') == 'slider' and not caps.get('inline')),
|
||||
('controls', options.get('controls', 'none') != 'none' and options.get('controls') not in caps.get('controls', ())),
|
||||
('display', options.get('display') in ('graph', 'forecast') and options.get('display') not in caps.get('displays', ())),
|
||||
)
|
||||
for key, refused in checks:
|
||||
if refused and (previous is None or before.get(key) != options.get(key)):
|
||||
return key, options.get(key)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# Domains whose tiles and cards show the raw state where no word of the screen's own fits (app 0.2.67): the app sends
|
||||
# Home Assistant's word for it. The screen's own words (On, Off, Docked, a binary sensor's Open) stay as they are.
|
||||
WORD_DOMAINS = frozenset(('cover', 'media_player', 'vacuum', 'select', 'input_select', 'sensor'))
|
||||
WORD_BYTES = 32
|
||||
|
||||
|
||||
def screen_word(entity_id, state, attributes, entry, words):
|
||||
"""The word a tile shows for its state, where the screen would show the raw state: folded to the glyphs its fonts
|
||||
carry and short enough for a tile; None when Home Assistant has no other word."""
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
if domain not in WORD_DOMAINS or (domain == 'sensor' and header_bar.numeric(state) is not None):
|
||||
return None
|
||||
word = state_word(entity_id, state, attributes, entry, words)
|
||||
if not word:
|
||||
return None
|
||||
word = header_bar.short(header_bar.clean_text(word), WORD_BYTES)
|
||||
return word if word and word != state else None
|
||||
|
||||
|
||||
def chip_words(extra, entity_id, states, device, words):
|
||||
"""A vacuum card's chips in Home Assistant's words where the screen has no short label of its own (app 0.2.67): a
|
||||
cleaning mode or mop intensity as the integration names the select's option, a suction level as it names the
|
||||
vacuum's fan speed. The screen's own labels ("Vac & mop", "Normal") stay; `extra` is changed in place."""
|
||||
if not isinstance(extra, dict) or not words:
|
||||
return extra
|
||||
entries = {item.get('entity_id'): item for item in device or () if isinstance(item, dict)}
|
||||
def relabel(row, word):
|
||||
if not isinstance(row, dict) or not isinstance(row.get('o'), list) or not isinstance(row.get('l'), list):
|
||||
return
|
||||
for index, value in enumerate(row['o'][:len(row['l'])]):
|
||||
found = None if value in core.VACUUM_LABELS else word(value)
|
||||
if found:
|
||||
row['l'][index] = header_bar.short(header_bar.clean_text(found), 24) or row['l'][index]
|
||||
for key in ('mode', 'water'):
|
||||
row = extra.get(key)
|
||||
select = row.get('e') if isinstance(row, dict) else None
|
||||
if isinstance(select, str):
|
||||
attributes = (states.get(select) or {}).get('attributes')
|
||||
relabel(row, lambda value: state_word(select, value, attributes, entries.get(select), words))
|
||||
attributes = (states.get(entity_id) or {}).get('attributes')
|
||||
relabel(extra.get('fan'), lambda value: attribute_word(entity_id, 'fan_speed', value, attributes, entries.get(entity_id), words))
|
||||
return extra
|
||||
@@ -15,7 +15,7 @@ import unicodedata
|
||||
from datetime import datetime, time
|
||||
|
||||
import tile_icons
|
||||
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short
|
||||
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word
|
||||
|
||||
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
|
||||
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
|
||||
@@ -142,8 +142,9 @@ def numeric(value):
|
||||
def short_date(moment):
|
||||
return f'{moment.day} {MONTHS[moment.month - 1]}'
|
||||
|
||||
def value(entity, state, entry=None, units=None, tz=None):
|
||||
"""(text, unix time) an entity shows as its state; exactly one of them is set."""
|
||||
def value(entity, state, entry=None, units=None, tz=None, words=None):
|
||||
"""(text, unix time) an entity shows as its state; exactly one of them is set. `words` are Home Assistant's
|
||||
translations: its word shows where the bar has none of its own ("Rinsing" for rinsing, app 0.2.67)."""
|
||||
domain = entity.split('.')[0]
|
||||
raw = state.get('state') if state else None
|
||||
attrs = (state or {}).get('attributes') or {}
|
||||
@@ -192,10 +193,10 @@ def value(entity, state, entry=None, units=None, tz=None):
|
||||
if number is not None and domain in ('sensor', 'number', 'input_number', 'counter', 'zone'):
|
||||
return with_unit(number_text(number, precision_of(entry)), attrs.get('unit_of_measurement')), None
|
||||
if domain in ('input_text', 'input_select', 'select'):
|
||||
return raw, None
|
||||
return state_word(entity, raw, attrs, entry, words) or raw, None
|
||||
if raw in STATES:
|
||||
return STATES[raw], None
|
||||
return raw[:1].upper() + raw[1:], None
|
||||
return state_word(entity, raw, attrs, entry, words) or raw[:1].upper() + raw[1:], None
|
||||
|
||||
def active(entity, state):
|
||||
""""Only when active": on, open, home, detected, playing, heating, or a number other than zero."""
|
||||
@@ -236,11 +237,12 @@ def accent(entity, state):
|
||||
return TEAL
|
||||
return None
|
||||
|
||||
def auto_icon(entity, state):
|
||||
"""Codepoint of the icon Home Assistant would show: its own mdi icon, else the device class, else the domain."""
|
||||
def auto_icon(entity, state, entry=None):
|
||||
"""Codepoint of the icon Home Assistant would show: its own mdi icon, else Home Assistant's default icon for this state
|
||||
(app 0.2.67, the same as a tile), else the device class and domain tables."""
|
||||
attrs = (state or {}).get('attributes') or {}
|
||||
raw = (state or {}).get('state')
|
||||
own = tile_icons.ha_icon(attrs)
|
||||
own = tile_icons.ha_icon(attrs) or tile_icons.default_glyph(entity, raw, attrs, entry)
|
||||
if own:
|
||||
return own
|
||||
domain, device_class = entity.split('.')[0], attrs.get('device_class')
|
||||
@@ -260,20 +262,20 @@ def auto_icon(entity, state):
|
||||
name = DOMAIN_ICONS.get(domain, tile_icons.FALLBACK)
|
||||
return tile_icons.GLYPHS[name]
|
||||
|
||||
def entity_item(item, states, registry=None, units=None, tz=None):
|
||||
def entity_item(item, states, registry=None, units=None, tz=None, words=None):
|
||||
"""(wire item, shown) for one entity item; the wire item exists even while hidden, for the editor."""
|
||||
entity = item['entity']
|
||||
state = states.get(entity)
|
||||
wire = {'k': 'text'}
|
||||
if item['icon'] == 'auto':
|
||||
wire['i'] = auto_icon(entity, state)
|
||||
wire['i'] = auto_icon(entity, state, (registry or {}).get(entity) if isinstance(registry, dict) else None)
|
||||
elif item['icon'] != 'none':
|
||||
wire['i'] = tile_icons.ICONS[item['icon']][0]
|
||||
if item['content'] == 'last_changed':
|
||||
moment = epoch((state or {}).get('last_changed'))
|
||||
text, moment = (None, moment) if moment else (UNAVAILABLE, None)
|
||||
else:
|
||||
text, moment = value(entity, state, (registry or {}).get(entity), units, tz)
|
||||
text, moment = value(entity, state, (registry or {}).get(entity), units, tz, words)
|
||||
if moment:
|
||||
wire.update(k='ago', e=moment)
|
||||
else:
|
||||
@@ -283,25 +285,25 @@ def entity_item(item, states, registry=None, units=None, tz=None):
|
||||
wire['c'] = color
|
||||
return wire, item['show'] == 'always' or active(entity, state)
|
||||
|
||||
def message(layout, states, registry=None, units=None, tz=None):
|
||||
def message(layout, states, registry=None, units=None, tz=None, words=None):
|
||||
items = []
|
||||
for item in header_items(layout):
|
||||
if item['type'] in HEADER_BUILTIN:
|
||||
items.append({'k': item['type']})
|
||||
continue
|
||||
wire, shown = entity_item(item, states, registry, units, tz)
|
||||
wire, shown = entity_item(item, states, registry, units, tz, words)
|
||||
if shown:
|
||||
items.append(wire)
|
||||
return {'v': 1, 'op': 'header', 'items': items}
|
||||
|
||||
def preview(header, states, registry=None, units=None, tz=None):
|
||||
def preview(header, states, registry=None, units=None, tz=None, words=None):
|
||||
"""What the editor's mockup shows per item, hidden ones included and marked."""
|
||||
result = []
|
||||
for item in header['items']:
|
||||
if item['type'] in HEADER_BUILTIN:
|
||||
result.append({'k': item['type'], 'name': HEADER_BUILTIN[item['type']], 'shown': True})
|
||||
continue
|
||||
wire, shown = entity_item(item, states, registry, units, tz)
|
||||
wire, shown = entity_item(item, states, registry, units, tz, words)
|
||||
attrs = (states.get(item['entity']) or {}).get('attributes') or {}
|
||||
result.append({**wire, 'name': attrs.get('friendly_name') or item['entity'], 'shown': shown,
|
||||
'auto_icon': auto_icon(item['entity'], states.get(item['entity']))})
|
||||
|
||||
@@ -11,6 +11,7 @@ import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import header_bar
|
||||
from core import state_word
|
||||
|
||||
RANGES = (1, 24, 168)
|
||||
POINTS = 24
|
||||
@@ -211,8 +212,9 @@ def line(entity, hours, changes, start, end, tz, entry=None, unit='', extreme_ch
|
||||
return message
|
||||
|
||||
|
||||
def state_label(domain, state, attrs):
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere."""
|
||||
def state_label(domain, state, attrs, word=None):
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere; `word` is Home Assistant's own word, for a
|
||||
state the screen has no word of its own for (app 0.2.67)."""
|
||||
if state in (None, 'unavailable'):
|
||||
return 'Unavailable'
|
||||
if state == 'unknown':
|
||||
@@ -222,7 +224,7 @@ def state_label(domain, state, attrs):
|
||||
return pair[0] if state == 'on' else pair[1] if state == 'off' else state
|
||||
if domain == 'person' and state not in header_bar.STATES:
|
||||
return state.replace('_', ' ')[:1].upper() + state.replace('_', ' ')[1:]
|
||||
return header_bar.STATES.get(state) or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
return header_bar.STATES.get(state) or word or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
|
||||
|
||||
def state_color(domain, state, others):
|
||||
@@ -237,10 +239,12 @@ def state_color(domain, state, others):
|
||||
return PALETTE[others % len(PALETTE)]
|
||||
|
||||
|
||||
def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
|
||||
def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS, entry=None, translations=None):
|
||||
"""The `history` message for states: the timeline as runs of slots, each state's time, and how often the
|
||||
active state began (a door opened, a person came home)."""
|
||||
active state began (a door opened, a person came home). `entry` and `translations` give Home Assistant's words."""
|
||||
domain = entity.split('.')[0]
|
||||
def label(state):
|
||||
return header_bar.clean_text(state_label(domain, state, attrs, state_word(entity, state, attrs, entry, translations)))[:24]
|
||||
changes = sorted(changes, key=lambda change: change[0])
|
||||
# The state in force over each stretch of the range.
|
||||
pieces, current, index = [], None, 0
|
||||
@@ -271,7 +275,7 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
|
||||
shown = ranked[:STATES_SHOWN] if len(ranked) <= STATES_SHOWN else ranked[:STATES_SHOWN - 1]
|
||||
index_of = {state: i for i, state in enumerate(shown)}
|
||||
others = [state for state in order if state_color(domain, state, 0) == PALETTE[0]]
|
||||
legend = [[header_bar.clean_text(state_label(domain, state, attrs))[:24],
|
||||
legend = [[label(state),
|
||||
state_color(domain, state, others.index(state) if state in others else 0), int(totals[state])] for state in shown]
|
||||
rest = [state for state in ranked if state not in index_of]
|
||||
if rest:
|
||||
@@ -300,7 +304,7 @@ def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
|
||||
shown_active = index_of.get(active, -1) if active in shown else -1
|
||||
return {'v': 1, 'op': 'history', 'entity': entity, 'hours': hours, 'kind': 'timeline', 'start': start, 'end': end,
|
||||
'off': utc_offset(end, tz), 'xt': time_ticks(start, end, hours, tz), 'slots': slots, 'states': legend,
|
||||
'seg': run_times(runs, pieces, index_of, start, span, slots), 'words': words(domain, order + [current], attrs),
|
||||
'seg': run_times(runs, pieces, index_of, start, span, slots), 'words': words(domain, order + [current], attrs, label),
|
||||
'began': began, 'active': shown_active}
|
||||
|
||||
|
||||
@@ -326,13 +330,13 @@ def run_times(runs, pieces, index_of, start, span, slots):
|
||||
return out
|
||||
|
||||
|
||||
def words(domain, states, attrs):
|
||||
def words(domain, states, attrs, label=None):
|
||||
"""[raw state, words] for the states of the range and a binary sensor's on and off: the card's heading shows the
|
||||
state now in these words, also after it changed while the card is open."""
|
||||
found = {}
|
||||
for state in states + (['on', 'off'] if domain == 'binary_sensor' else []):
|
||||
if isinstance(state, str) and len(state) <= 32 and state not in found and len(found) < 10:
|
||||
found[state] = header_bar.clean_text(state_label(domain, state, attrs))[:24]
|
||||
found[state] = label(state) if label else header_bar.clean_text(state_label(domain, state, attrs))[:24]
|
||||
return [[state, text] for state, text in found.items()]
|
||||
|
||||
|
||||
|
||||
+171
-15
@@ -13,12 +13,13 @@ import math
|
||||
import camera_feed
|
||||
import claude_skill
|
||||
from firmware import Firmware
|
||||
import ha_catalogue
|
||||
import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
|
||||
import header_bar
|
||||
import history_card
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -106,6 +107,18 @@ class HomeAssistant:
|
||||
# Assistant's own list; refreshed when a device registers its actions again.
|
||||
self.responses = set()
|
||||
self.services_changed = asyncio.Event()
|
||||
# Every action Home Assistant describes (get_services), which the editor's choices per entity follow (app
|
||||
# 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and
|
||||
# `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+).
|
||||
self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None
|
||||
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English
|
||||
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10.
|
||||
self.service_names = {}
|
||||
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a
|
||||
# tile shows where it would show the raw state (app 0.2.67).
|
||||
self.state_words = {}
|
||||
self.esphome_services = False
|
||||
self._platforms_source, self._platforms = None, {}
|
||||
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
|
||||
self.history_requests = asyncio.Queue()
|
||||
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
|
||||
@@ -159,8 +172,10 @@ class HomeAssistant:
|
||||
elif event.get('event_type') in REGISTRY_EVENTS:
|
||||
self.registry_changed.set()
|
||||
elif event.get('event_type') in SERVICE_EVENTS:
|
||||
if body.get('domain') == 'esphome':
|
||||
self.services_changed.set()
|
||||
# Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide
|
||||
# how the app talks to it.
|
||||
self.esphome_services |= body.get('domain') == 'esphome'
|
||||
self.services_changed.set()
|
||||
elif event.get('event_type') in BROADCAST_EVENTS:
|
||||
# Queued, not handled here: the calls wait for results this reader has to deliver.
|
||||
self.broadcasts.put_nowait((event['event_type'], body))
|
||||
@@ -182,9 +197,34 @@ class HomeAssistant:
|
||||
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
|
||||
|
||||
async def fetch_services(self):
|
||||
"""Which ESPHome actions answer: Home Assistant lists `response` for an action that can return one."""
|
||||
esphome = (await self.request('get_services') or {}).get('esphome', {})
|
||||
"""Every action Home Assistant describes: which ESPHome actions answer (Home Assistant lists `response` for an
|
||||
action that can return one), and the descriptions the editor's choices per entity follow (app 0.2.67)."""
|
||||
services = await self.request('get_services') or {}
|
||||
esphome = services.get('esphome', {})
|
||||
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
|
||||
if isinstance(services, dict):
|
||||
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
|
||||
try:
|
||||
names = await self.request('frontend/get_translations', language='en', category='services')
|
||||
if isinstance((names or {}).get('resources'), dict):
|
||||
self.service_names = names['resources']
|
||||
words = {}
|
||||
for category in ('entity_component', 'entity'):
|
||||
found = await self.request('frontend/get_translations', language='en', category=category)
|
||||
if isinstance((found or {}).get('resources'), dict):
|
||||
words.update(found['resources'])
|
||||
if words:
|
||||
self.state_words = words
|
||||
icons = {}
|
||||
for category in ('entity_component', 'entity'):
|
||||
found = await self.request('frontend/get_icons', category=category)
|
||||
if isinstance((found or {}).get('resources'), dict):
|
||||
icons[category] = found['resources']
|
||||
if icons:
|
||||
tile_icons.use_ha_icons(icons)
|
||||
except (Refused, TimeoutError) as error:
|
||||
# The editor then shows the names services.yaml still carries, and tiles keep their raw states.
|
||||
LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__)
|
||||
|
||||
async def refresh_services(self):
|
||||
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
|
||||
@@ -194,6 +234,49 @@ class HomeAssistant:
|
||||
LOG.warning('Reading the actions from Home Assistant failed (%s); screens are not asked for answers', type(error).__name__)
|
||||
self.responses = set()
|
||||
|
||||
def platform_of(self, entity_id):
|
||||
"""The integration behind an entity, from the entity registry (an action can be meant for one integration)."""
|
||||
registry = self.registry
|
||||
if self._platforms_source is not registry:
|
||||
self._platforms_source = registry
|
||||
self._platforms = {item.get('entity_id'): item.get('platform') for item in registry if isinstance(item, dict)}
|
||||
return self._platforms.get(entity_id)
|
||||
|
||||
async def entity_actions(self, entity_id):
|
||||
"""The actions Home Assistant offers for one entity, or None while that is unknown (no action list yet, or no
|
||||
state). Home Assistant 2025.12+ answers get_services_for_target itself; an older one gets the same answer from the
|
||||
action descriptions."""
|
||||
state = self.states.get(entity_id)
|
||||
if state is None or not self.services:
|
||||
return None
|
||||
attributes = state.get('attributes') or {}
|
||||
key = (self.services_rev, id(self.registry), attributes.get('supported_features'), attributes.get('device_class'))
|
||||
cached = self.targets.get(entity_id)
|
||||
if cached and cached[0] == key:
|
||||
return cached[1]
|
||||
actions = None
|
||||
if self.target_lookup is not False:
|
||||
try:
|
||||
answer = await self.request('get_services_for_target', target={'entity_id': [entity_id]}, expand_group=False)
|
||||
if isinstance(answer, list):
|
||||
actions, self.target_lookup = frozenset(answer), True
|
||||
except Refused as error:
|
||||
if 'unknown command' in (error.detail or '').lower():
|
||||
self.target_lookup = False
|
||||
except (ConnectionError, TimeoutError):
|
||||
pass
|
||||
if actions is None:
|
||||
actions = frozenset(ha_catalogue.local_actions(self.services, entity_id, attributes, self.platform_of(entity_id)))
|
||||
self.targets[entity_id] = (key, actions)
|
||||
return actions
|
||||
|
||||
async def capabilities(self, entity_id):
|
||||
"""What the editor may offer for one entity (ha_catalogue.capabilities), or None while Home Assistant can't say."""
|
||||
actions = await self.entity_actions(entity_id)
|
||||
if actions is None:
|
||||
return None
|
||||
return ha_catalogue.capabilities(entity_id, actions, self.states.get(entity_id), self.services)
|
||||
|
||||
async def run(self):
|
||||
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
|
||||
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
|
||||
@@ -243,7 +326,9 @@ class HomeAssistant:
|
||||
await asyncio.sleep(1)
|
||||
self.services_changed.clear()
|
||||
await self.refresh_services()
|
||||
self.changed.set()
|
||||
if self.esphome_services:
|
||||
self.esphome_services = False
|
||||
self.changed.set()
|
||||
if self.registry_changed.is_set():
|
||||
await asyncio.sleep(1)
|
||||
self.registry_changed.clear()
|
||||
@@ -425,7 +510,8 @@ class Manager:
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict):
|
||||
raise ValueError('Unknown storage version; data stays unchanged.')
|
||||
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
|
||||
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
|
||||
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
|
||||
|
||||
def inventory(self):
|
||||
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
|
||||
@@ -763,7 +849,8 @@ class Manager:
|
||||
tz = getattr(self.ha, 'time_zone', None)
|
||||
attrs = (self.ha.states.get(entity) or {}).get('attributes') or {}
|
||||
if what == 'timeline':
|
||||
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs)
|
||||
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs,
|
||||
entry=self.registry_index().get(entity), translations=getattr(self.ha, 'state_words', None))
|
||||
entry, unit = self.registry_index().get(entity), attrs.get('unit_of_measurement') or ''
|
||||
rows = await self.ha.statistic_rows(entity, hours) if hours > 1 else []
|
||||
if rows:
|
||||
@@ -802,7 +889,8 @@ class Manager:
|
||||
return ()
|
||||
|
||||
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))
|
||||
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))
|
||||
|
||||
def needs_firmware(self, inbox, layout, screen=None):
|
||||
"""Version string the screen must run first, or None when the layout can be sent."""
|
||||
@@ -872,6 +960,32 @@ class Manager:
|
||||
self.ha.changed.set()
|
||||
self.notify()
|
||||
|
||||
async def check_supported(self, inbox, data):
|
||||
"""Refuse a tile setting Home Assistant doesn't support for its entity, before saving (app 0.2.67): On / off on a
|
||||
speaker that can't turn on and off, a small slider on a light without brightness. A setting a tile already has
|
||||
stays, and nothing is refused while Home Assistant can't say."""
|
||||
capabilities = getattr(self.ha, 'capabilities', None)
|
||||
if capabilities is None:
|
||||
return
|
||||
layout = validate_layout(data)
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
before = {tile['entity']: tile for tile in self.layouts.get(inbox, {}).get('tiles', [])}
|
||||
for tile in layout['tiles']:
|
||||
previous = before.get(tile['entity'])
|
||||
if tile['entity'] in BUILTIN or not tile.get('options') or (previous or {}).get('options') == tile['options']:
|
||||
continue
|
||||
name = tile.get('name') or self.ha.states.get(tile['entity'], {}).get('attributes', {}).get('friendly_name') or tile['entity']
|
||||
found = ha_catalogue.unsupported(tile, previous, await capabilities(tile['entity']))
|
||||
if found:
|
||||
raise ValueError(ha_catalogue.refusal(tile['entity'], name, *found))
|
||||
# Perform action (app 0.2.67): an action Home Assistant offers for the entity, with its required fields.
|
||||
action = tile['options'].get('action')
|
||||
if tile['options'].get('tap') == 'action' and action != ((previous or {}).get('options') or {}).get('action'):
|
||||
problem = ha_catalogue.action_problem(tile['entity'], name, action, self.ha.states.get(tile['entity']),
|
||||
await self.ha.entity_actions(tile['entity']), self.ha.services)
|
||||
if problem:
|
||||
raise ValueError(problem)
|
||||
|
||||
def notify(self):
|
||||
for listener in self.listeners:
|
||||
listener.set()
|
||||
@@ -931,7 +1045,17 @@ class Manager:
|
||||
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
|
||||
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
|
||||
entry=self.registry_index().get(tile['entity'])
|
||||
extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device)
|
||||
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,
|
||||
precision=header_bar.precision_of(entry) if tile['entity'].startswith('sensor.') else None,entry=entry)
|
||||
# Home Assistant's word where the screen would show the raw state (firmware 0.2.58+ shows it).
|
||||
state=self.ha.states.get(tile['entity'],{})
|
||||
word=ha_catalogue.screen_word(tile['entity'],message['state'],state.get('attributes'),entry,getattr(self.ha,'state_words',None))
|
||||
if word:
|
||||
message.setdefault('x',{})['w']=word
|
||||
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
|
||||
hours=tile.get('options',{}).get('history_hours',24)
|
||||
entry=self.histories.get((tile['entity'],hours))
|
||||
@@ -1289,8 +1413,9 @@ class Manager:
|
||||
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does."""
|
||||
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
|
||||
inbox = self.aliases.get(screen['id'], screen['id'])
|
||||
layout = self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}
|
||||
self.save(inbox, apply_tile_event(layout, TILE_EVENTS[event_type], data))
|
||||
layout = apply_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data)
|
||||
await self.check_supported(inbox, layout)
|
||||
self.save(inbox, layout)
|
||||
return screen
|
||||
|
||||
async def tile_loop(self):
|
||||
@@ -1411,7 +1536,8 @@ def create_app(manager, development=False):
|
||||
data = await request.json()
|
||||
header = validate_header(data.get('header') if isinstance(data, dict) else None)
|
||||
return web.json_response({'items': header_bar.preview(header, manager.ha.states, manager.registry_index(),
|
||||
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None))})
|
||||
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None),
|
||||
getattr(manager.ha, 'state_words', None))})
|
||||
async def events(request):
|
||||
"""Server-sent events: pushes the light inventory whenever it changes, so the page need not poll."""
|
||||
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store',
|
||||
@@ -1438,8 +1564,33 @@ def create_app(manager, development=False):
|
||||
manager.listeners.discard(wake)
|
||||
return response
|
||||
async def save(request):
|
||||
manager.save(request.match_info['inbox'], await request.json())
|
||||
data = await request.json()
|
||||
await manager.check_supported(request.match_info['inbox'], data)
|
||||
manager.save(request.match_info['inbox'], data)
|
||||
return web.json_response({'saved': True})
|
||||
async def capabilities(request):
|
||||
"""What Home Assistant says each entity can do, for the tile settings (app 0.2.67). Unknown is null: the editor
|
||||
then offers what it always offered."""
|
||||
entities = list(dict.fromkeys(request.query.getall('entity', [])))[:40]
|
||||
lookup = getattr(manager.ha, 'capabilities', None)
|
||||
async def one(entity):
|
||||
try:
|
||||
return await lookup(entity) if lookup else None
|
||||
except Exception as error:
|
||||
LOG.info('No capabilities for %s (%s)', entity, type(error).__name__)
|
||||
return None
|
||||
found = await asyncio.gather(*(one(entity) for entity in entities))
|
||||
return web.json_response({'capabilities': dict(zip(entities, found))})
|
||||
async def entity_actions(request):
|
||||
"""Perform action (app 0.2.67): the actions Home Assistant offers for one entity, with its names and the fields it
|
||||
offers for that entity. Null while Home Assistant can't say."""
|
||||
entity = request.query.get('entity', '')
|
||||
ha = manager.ha
|
||||
actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
|
||||
if actions is None:
|
||||
return web.json_response({'actions': None})
|
||||
return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services,
|
||||
getattr(ha, 'service_names', {}), ha.platform_of(entity))})
|
||||
async def change_settings(request):
|
||||
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
|
||||
data = await request.json()
|
||||
@@ -1466,6 +1617,9 @@ def create_app(manager, development=False):
|
||||
return web.json_response({'screen':screen,
|
||||
'delivery':manager.status.get(inbox), 'layout':layout,
|
||||
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
|
||||
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
|
||||
'word':state_word(t['entity'],manager.ha.states.get(t['entity'],{}).get('state'),manager.ha.states.get(t['entity'],{}).get('attributes'),
|
||||
manager.registry_index().get(t['entity']),getattr(manager.ha,'state_words',None)),
|
||||
'attributes':state_message(i,t,manager.ha.states)['a'],
|
||||
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
|
||||
async def install_claude_skill(request):
|
||||
@@ -1504,6 +1658,8 @@ def create_app(manager, development=False):
|
||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
app.router.add_get('/api/capabilities', capabilities)
|
||||
app.router.add_get('/api/entity-actions', entity_actions)
|
||||
app.router.add_post('/api/header-preview', header_preview)
|
||||
app.router.add_post('/api/claude-skill', install_claude_skill)
|
||||
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
|
||||
|
||||
@@ -85,6 +85,35 @@ function select(id) {
|
||||
renderResults();
|
||||
renderSettings();
|
||||
loadTopbarPreview(0);
|
||||
loadCapabilities(layout.tiles.map((t) => t.entity));
|
||||
}
|
||||
// What Home Assistant says each entity can do (app 0.2.67): On / off, a small slider, direct controls and the
|
||||
// graph and forecast displays follow its own action list, so a device that can do more gets more. Unknown (Home
|
||||
// Assistant not answering) is null, and the sheet then offers what it always did. A choice a tile already has stays
|
||||
// visible with a warning instead of disappearing.
|
||||
const capabilities = {}, askedCapabilities = new Set();
|
||||
const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
|
||||
const SLIDER_DOMAINS = ["light", "fan", "cover", "number", "input_number", "media_player"];
|
||||
async function loadCapabilities(entities) {
|
||||
const wanted = [...new Set(entities)].filter((id) => !askedCapabilities.has(id) && !id.startsWith("screen."));
|
||||
if (!wanted.length) return;
|
||||
wanted.forEach((id) => askedCapabilities.add(id));
|
||||
try {
|
||||
for (let i = 0; i < wanted.length; i += 40) {
|
||||
const query = wanted.slice(i, i + 40).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
|
||||
Object.assign(capabilities, (await (await api(`capabilities?${query}`)).json()).capabilities || {});
|
||||
}
|
||||
} catch {
|
||||
wanted.forEach((id) => askedCapabilities.delete(id));
|
||||
return;
|
||||
}
|
||||
if (sheetIndex >= 0 && wanted.includes(layout?.tiles[sheetIndex]?.entity)) renderTileSheet();
|
||||
}
|
||||
// A hint under a field sits beside its label, not in it: a click on a label presses the first choice inside.
|
||||
function withHint(wrap, text, warn = false) {
|
||||
const group = node("div", undefined, "sheet-hinted");
|
||||
group.append(wrap, node("small", text, warn ? "field-hint warn" : "field-hint"));
|
||||
return group;
|
||||
}
|
||||
// ----- Screen settings: the same groups and rows as the settings page on the screen itself -----
|
||||
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
|
||||
@@ -802,6 +831,7 @@ const domains = {
|
||||
select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
input_button: ["Action", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
@@ -934,6 +964,7 @@ function commit(result) {
|
||||
return changed;
|
||||
}
|
||||
function placeTile(tile, target) {
|
||||
loadCapabilities([tile.entity]);
|
||||
const result = arrange(layout.tiles, tile, target);
|
||||
return result ? commit(result) : false;
|
||||
}
|
||||
@@ -1135,7 +1166,8 @@ function removeTile(index) {
|
||||
// so the card behind it shows the result while you pick.
|
||||
let sheetIndex = -1, iconPickerOpen = false;
|
||||
function openTileSheet(index) {
|
||||
if (index !== sheetIndex) iconPickerOpen = false;
|
||||
loadCapabilities([layout.tiles[index].entity]);
|
||||
if (index !== sheetIndex) { iconPickerOpen = false; actionPickerOpen = false; actionSearch = ""; }
|
||||
sheetIndex = index;
|
||||
selectedTile = layout.tiles[index].entity;
|
||||
renderTileSheet();
|
||||
@@ -1260,6 +1292,169 @@ function iconPicker({ selected, automatic, autoLabel, allowNone = false, onPick,
|
||||
if (note) wrap.append(node("small", note, "icon-hint"));
|
||||
return wrap;
|
||||
}
|
||||
// ----- Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names -----
|
||||
const entityActions = {}, askedActions = new Set();
|
||||
let actionPickerOpen = false, actionSearch = "";
|
||||
async function loadEntityActions(entity) {
|
||||
if (askedActions.has(entity)) return;
|
||||
askedActions.add(entity);
|
||||
try {
|
||||
entityActions[entity] = (await (await api(`entity-actions?entity=${encodeURIComponent(entity)}`)).json()).actions;
|
||||
} catch {
|
||||
askedActions.delete(entity);
|
||||
return;
|
||||
}
|
||||
if (sheetIndex >= 0 && layout?.tiles[sheetIndex]?.entity === entity) renderTileSheet();
|
||||
}
|
||||
// One field of an action, in the kind of value Home Assistant asks for (its selector). An empty field is left out.
|
||||
function actionField(field, value, onChange) {
|
||||
const selector = field.selector || {}, kind = Object.keys(selector)[0] || "text", config = selector[kind] || {};
|
||||
const wrap = node("div", undefined, "sheet-field action-field");
|
||||
wrap.append(node("span", field.required ? field.name : `${field.name} (optional)`));
|
||||
const example = field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example);
|
||||
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
|
||||
const choices = Array.isArray(field.options) ? field.options.map((o) => [o, o])
|
||||
: kind === "select" && Array.isArray(config.options) && !config.multiple
|
||||
? config.options.map((o) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]))
|
||||
: null;
|
||||
if (kind === "boolean") {
|
||||
wrap.append(segmented([["", "Not set"], ["true", "On"], ["false", "Off"]], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v === "true")));
|
||||
} else if (choices && choices.length <= 4) {
|
||||
wrap.append(segmented([["", "Not set"], ...choices], value === undefined ? "" : String(value), (v) => onChange(v === "" ? undefined : v)));
|
||||
} else if (choices) {
|
||||
const list = node("select");
|
||||
list.setAttribute("aria-label", field.name);
|
||||
for (const [key, text] of [["", "Not set"], ...choices]) {
|
||||
const option = node("option", text);
|
||||
option.value = key;
|
||||
option.selected = String(value ?? "") === key;
|
||||
list.append(option);
|
||||
}
|
||||
list.onchange = () => onChange(list.value === "" ? undefined : list.value);
|
||||
wrap.append(list);
|
||||
} else if (kind === "color_rgb") {
|
||||
const row = node("div", undefined, "action-number"), input = node("input"), clear = node("button", "Not set", "quiet");
|
||||
input.type = "color";
|
||||
input.setAttribute("aria-label", field.name);
|
||||
const hex = (rgb) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
|
||||
input.value = Array.isArray(value) && value.length === 3 ? hex(value) : "#ffffff";
|
||||
input.oninput = () => onChange([1, 3, 5].map((i) => parseInt(input.value.slice(i, i + 2), 16)));
|
||||
clear.type = "button";
|
||||
clear.onclick = () => onChange(undefined);
|
||||
row.append(input, clear);
|
||||
wrap.append(row);
|
||||
} else if (kind === "number" || kind === "color_temp") {
|
||||
const row = node("div", undefined, "action-number"), input = node("input");
|
||||
input.type = "number";
|
||||
input.setAttribute("aria-label", field.name);
|
||||
for (const key of ["min", "max", "step"]) if (typeof config[key] === "number") input[key] = config[key];
|
||||
if (config.step === "any") input.step = "any";
|
||||
input.value = value ?? "";
|
||||
input.placeholder = example;
|
||||
input.oninput = () => onChange(input.value === "" || !Number.isFinite(Number(input.value)) ? undefined : Number(input.value));
|
||||
row.append(input);
|
||||
const unit = config.unit_of_measurement || (kind === "color_temp" ? config.unit || "" : "");
|
||||
if (unit) row.append(node("span", unit));
|
||||
wrap.append(row);
|
||||
} else {
|
||||
// Text as it is; anything else Home Assistant asks for (a list, an object, a time) as JSON, or as text when it isn't.
|
||||
const input = node("input");
|
||||
input.type = "text";
|
||||
input.setAttribute("aria-label", field.name);
|
||||
input.value = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
||||
input.placeholder = example;
|
||||
input.oninput = () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return onChange(undefined);
|
||||
if (kind === "text") return onChange(input.value);
|
||||
try { onChange(JSON.parse(text)); } catch { onChange(input.value); }
|
||||
};
|
||||
wrap.append(input);
|
||||
}
|
||||
if (field.description) wrap.append(node("small", field.description, "field-hint"));
|
||||
return wrap;
|
||||
}
|
||||
function actionPicker(tile) {
|
||||
const wrap = node("div", undefined, "sheet-field");
|
||||
wrap.append(node("span", "Action"));
|
||||
const list = entityActions[tile.entity], chosen = tile.options?.action;
|
||||
if (list === undefined) {
|
||||
wrap.append(node("small", "Asking Home Assistant which actions this entity has…", "field-hint"));
|
||||
loadEntityActions(tile.entity);
|
||||
return wrap;
|
||||
}
|
||||
if (list === null) {
|
||||
wrap.append(node("small", "Home Assistant isn't answering right now. Try again in a moment.", "field-hint warn"));
|
||||
return wrap;
|
||||
}
|
||||
const entry = list.find((a) => a.action === chosen?.action);
|
||||
const open = actionPickerOpen || !chosen;
|
||||
const summary = node("button", undefined, "icon-current action-current");
|
||||
summary.type = "button";
|
||||
summary.setAttribute("aria-expanded", String(open));
|
||||
const text = node("span");
|
||||
text.append(node("strong", entry ? entry.name : chosen ? chosen.action : "Choose an action"));
|
||||
if (chosen) text.append(node("small", chosen.action, "action-id"));
|
||||
summary.append(text, node("small", open ? "Close" : "Change"));
|
||||
summary.onclick = () => { actionPickerOpen = !open; renderTileSheet(); };
|
||||
const panel = node("div", undefined, "icon-picker");
|
||||
panel.hidden = !open;
|
||||
const search = node("input"), rows = node("div", undefined, "action-list"), empty = node("p", "No action found.", "hint");
|
||||
search.type = "search";
|
||||
search.placeholder = "Search, for example toggle, position, or play";
|
||||
search.setAttribute("aria-label", "Search for an action");
|
||||
search.value = actionSearch;
|
||||
for (const action of list) {
|
||||
const b = node("button", undefined, "action-choice");
|
||||
b.type = "button";
|
||||
b.dataset.search = `${action.name} ${action.action} ${action.description}`.toLocaleLowerCase();
|
||||
b.setAttribute("aria-pressed", String(action.action === chosen?.action));
|
||||
b.append(node("strong", action.name), node("small", action.description ? `${action.action} · ${action.description}` : action.action));
|
||||
b.onclick = () => {
|
||||
const same = action.action === chosen?.action;
|
||||
tile.options = { ...tile.options, action: same ? chosen : { action: action.action } };
|
||||
actionPickerOpen = false;
|
||||
markDirty();
|
||||
renderTileSheet();
|
||||
};
|
||||
rows.append(b);
|
||||
}
|
||||
const filter = () => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
let found = 0;
|
||||
for (const b of rows.children) { b.hidden = !b.dataset.search.includes(query); if (!b.hidden) found++; }
|
||||
empty.hidden = found > 0;
|
||||
};
|
||||
search.oninput = () => { actionSearch = search.value; filter(); };
|
||||
filter();
|
||||
panel.append(search, rows, empty);
|
||||
wrap.append(summary, panel);
|
||||
if (chosen && !entry)
|
||||
wrap.append(node("small", "Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.", "field-hint warn"));
|
||||
if (entry && !open) {
|
||||
const missing = node("small", "", "field-hint warn");
|
||||
const check = () => {
|
||||
const data = tile.options.action?.data || {};
|
||||
const names = entry.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
|
||||
missing.textContent = names.length ? `Home Assistant needs ${names.join(", ")}.` : "";
|
||||
missing.hidden = !names.length;
|
||||
};
|
||||
for (const field of entry.fields)
|
||||
wrap.append(actionField(field, chosen.data?.[field.key], (value) => {
|
||||
const data = { ...(tile.options.action?.data || {}) };
|
||||
if (value === undefined) delete data[field.key];
|
||||
else data[field.key] = value;
|
||||
tile.options = { ...tile.options, action: { action: chosen.action, ...(Object.keys(data).length ? { data } : {}) } };
|
||||
markDirty();
|
||||
check();
|
||||
}));
|
||||
check();
|
||||
wrap.append(missing);
|
||||
}
|
||||
if (!supportsFirmware(0, 2, 58))
|
||||
wrap.append(node("small", "The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.", "field-hint"));
|
||||
return wrap;
|
||||
}
|
||||
function renderTileSheet() {
|
||||
const sheet = $("#tile-sheet"), tile = layout.tiles[sheetIndex];
|
||||
if (!tile) return;
|
||||
@@ -1283,13 +1478,15 @@ function renderTileSheet() {
|
||||
// The clock, forecast and sun path cards draw no tile icon.
|
||||
if (inventory.icons && domain !== "screen" && !["forecast", "sunpath"].includes(tile.options?.display))
|
||||
body.append(iconField(tile, () => { const next = tileBadge(tile); badge.replaceWith(next); badge = next; }));
|
||||
const caps = capabilities[tile.entity];
|
||||
const current = (key, fallback) => tile.options?.[key] ?? fallback;
|
||||
const display = current("display", domain === "screen" ? "digital" : "standard");
|
||||
const displays = domain === "screen"
|
||||
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
|
||||
: [["standard", "Name and status"], ["watch", "Large value"]];
|
||||
if (domain === "weather") displays.push(["forecast", "Weather forecast"]);
|
||||
if (domain === "sensor") displays.push(["graph", "Graph"]);
|
||||
if (domain === "weather" && (!caps || caps.displays.includes("forecast") || display === "forecast")) displays.push(["forecast", "Weather forecast"]);
|
||||
if (domain === "sensor" && (!caps || caps.displays.includes("graph") || display === "graph")) displays.push(["graph", "Graph"]);
|
||||
if (domain === "sun") displays.push(["sunpath", "Sun path"]);
|
||||
const current = (key, fallback) => tile.options?.[key] ?? fallback;
|
||||
const set = (key, value) => {
|
||||
const wasWide = isWide(tile);
|
||||
tile.options = { ...tile.options, [key]: value };
|
||||
@@ -1298,6 +1495,10 @@ function renderTileSheet() {
|
||||
if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide";
|
||||
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
|
||||
// A card that becomes wide gets the first direct control Home Assistant offers when the usual one isn't there.
|
||||
const catalogue = inventory.controls?.[domain];
|
||||
if (key === "size" && value === "wide" && caps && catalogue && !("controls" in tile.options) && !caps.controls.includes(catalogue.default))
|
||||
tile.options.controls = catalogue.choices.find((c) => c.key !== "none" && caps.controls.includes(c.key))?.key || "none";
|
||||
markDirty();
|
||||
// A card that becomes double-wide keeps its row when the cell beside it is free, else it
|
||||
// takes the nearest free row (below first); every other tile stays where it is.
|
||||
@@ -1308,23 +1509,46 @@ function renderTileSheet() {
|
||||
}
|
||||
renderTiles();
|
||||
};
|
||||
body.append(field("Display", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
|
||||
const displayField = field("Display", segmented(displays, display, (v) => set("display", v)));
|
||||
if (caps && display === "graph" && !caps.displays.includes("graph"))
|
||||
body.append(withHint(displayField, "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.", true));
|
||||
else if (caps && display === "forecast" && !caps.displays.includes("forecast"))
|
||||
body.append(withHint(displayField, "This weather service has no daily forecast in Home Assistant. Choose another display.", true));
|
||||
else body.append(displayField);
|
||||
body.append(field("Width", segmented([["single", "Normal"], ["wide", "Double-width"]], current("size", "single"), (v) => set("size", v))));
|
||||
const catalogue = inventory.controls?.[domain];
|
||||
if (catalogue && current("size", "single") === "wide") {
|
||||
const wrap = field("Direct control on the tile", segmented(catalogue.choices.map((c) => [c.key, c.label]), current("controls", catalogue.default), (v) => set("controls", v)));
|
||||
wrap.append(node("small", supportsFirmware(0, 2, 19)
|
||||
const chosen = current("controls", catalogue.default);
|
||||
const choices = catalogue.choices.filter((c) => !caps || c.key === "none" || c.key === chosen || caps.controls.includes(c.key));
|
||||
const wrap = field("Direct control on the tile", segmented(choices.map((c) => [c.key, c.label]), chosen, (v) => set("controls", v)));
|
||||
if (caps && chosen !== "none" && !caps.controls.includes(chosen))
|
||||
body.append(withHint(wrap, "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", true));
|
||||
else body.append(withHint(wrap, supportsFirmware(0, 2, 19)
|
||||
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as configured below."
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", "field-hint"));
|
||||
body.append(wrap);
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was."));
|
||||
}
|
||||
if (domain !== "screen") {
|
||||
const tap = current("tap", "auto");
|
||||
const taps = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
if (["light", "switch", "input_boolean", "fan", "media_player", "climate"].includes(domain)) taps.push(["toggle", "On / off"]);
|
||||
body.append(field("On tap", segmented(taps, current("tap", "auto"), (v) => set("tap", v))));
|
||||
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
|
||||
if ((caps ? caps.toggle : TOGGLE_BEFORE.includes(domain)) || tap === "toggle") taps.push(["toggle", "On / off"]);
|
||||
taps.push(["action", "Perform action"]);
|
||||
const wrap = field("On tap", segmented(taps, tap, (v) => set("tap", v)));
|
||||
if (tap === "toggle" && caps && !caps.toggle)
|
||||
body.append(withHint(wrap, "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", true));
|
||||
else if (tap === "toggle" && !TOGGLE_BEFORE.includes(domain) && !supportsFirmware(0, 2, 58))
|
||||
body.append(withHint(wrap, "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card."));
|
||||
else if (tap === "toggle") body.append(withHint(wrap, "Hold the tile to open its card."));
|
||||
else body.append(wrap);
|
||||
if (tap === "action") body.append(actionPicker(tile));
|
||||
}
|
||||
const inline = current("inline", "none");
|
||||
if (SLIDER_DOMAINS.includes(domain) && (!caps || caps.inline || inline === "slider")) {
|
||||
const wrap = field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], inline, (v) => set("inline", v)));
|
||||
if (inline === "slider" && caps && !caps.inline)
|
||||
body.append(withHint(wrap, "Home Assistant has nothing a slider can change for this entity. Choose No.", true));
|
||||
else body.append(wrap);
|
||||
}
|
||||
if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
|
||||
body.append(field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], current("inline", "none"), (v) => set("inline", v))));
|
||||
if (domain === "sensor")
|
||||
body.append(field("History", segmented([[1, "1 hour"], [6, "6 hours"], [24, "24 hours"]], current("history_hours", 24), (v) => set("history_hours", Number(v)))));
|
||||
const palette = node("div", undefined, "sheet-field");
|
||||
@@ -2007,6 +2231,10 @@ function endDrag(drop) {
|
||||
if (!(drop && preview && commit(preview))) renderPreview();
|
||||
}
|
||||
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
// The picker offers what a tile can show: the inventory also lists locks, trackers and counters for the top bar (tile: false).
|
||||
function pickable(entity) {
|
||||
return entity.tile !== false;
|
||||
}
|
||||
function renderResults() {
|
||||
if (!layout) return;
|
||||
const query = $("#search").value.toLocaleLowerCase();
|
||||
@@ -2015,9 +2243,10 @@ function renderResults() {
|
||||
const guition = inventory.screens.find((s) => s.id === selected)?.board === "guition";
|
||||
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
|
||||
(e) =>
|
||||
pickable(e) &&
|
||||
(guition || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") ||
|
||||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
|
||||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun", button:"input_button"}[filter] === e.id.split(".")[0])) &&
|
||||
`${e.name} ${e.id} ${e.device} ${e.area}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query),
|
||||
@@ -2541,7 +2770,7 @@ async function inspect(entity) {
|
||||
const card = node("article", undefined, "inspection-tile");
|
||||
card.append(
|
||||
node("strong", tile.entity),
|
||||
node("p", `Status: ${tile.state}`),
|
||||
node("p", `Status: ${tile.word || tile.state}`),
|
||||
);
|
||||
const options =
|
||||
layout.tiles.find((t) => t.entity === tile.entity)?.options || {};
|
||||
|
||||
@@ -1158,6 +1158,8 @@ pre {
|
||||
.preview-toggle::after { content: ""; position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; border-radius: 50%; background: white; }
|
||||
.preview-run { height: 22px; padding: 0 10px; border-radius: 11px; background: #eceef1; font-size: 12px; font-weight: 600; line-height: 22px; }
|
||||
.field-hint { font-weight: 400; }
|
||||
.sheet-hinted { display: grid; gap: 7px; font-size: 12px; color: var(--muted); }
|
||||
.field-hint.warn { color: #9a6200; }
|
||||
.preview-tile small { flex-basis: 100%; }
|
||||
.result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; }
|
||||
|
||||
@@ -1225,6 +1227,18 @@ pre {
|
||||
.icon-choice.icon-auto .mdi { font-size: 22px; }
|
||||
.icon-picker .hint { margin: 0; }
|
||||
.icon-hint { font-weight: 400; }
|
||||
/* Perform action: Home Assistant's actions for the entity, then the fields of the chosen one. */
|
||||
.action-current > span { display: grid; gap: 2px; flex: 1; min-width: 0; padding-left: 5px; }
|
||||
.action-current .action-id { color: var(--muted); font-weight: 400; }
|
||||
.action-list { display: grid; gap: 6px; max-height: 300px; overflow: auto; padding-right: 2px; }
|
||||
.action-choice { display: grid; gap: 2px; padding: 8px 12px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; text-align: left; }
|
||||
.action-choice strong { font-weight: 600; }
|
||||
.action-choice small { color: var(--muted); font-weight: 400; }
|
||||
.action-choice[aria-pressed="true"] { background: var(--soft); border-color: #9dbcf5; box-shadow: inset 0 0 0 1px #9dbcf5; }
|
||||
.action-field { margin-top: 4px; }
|
||||
.action-field input, .action-field select { font-size: 14px; font-weight: 400; }
|
||||
.action-number { display: flex; align-items: center; gap: 8px; }
|
||||
.action-number input { flex: 1; min-width: 0; }
|
||||
|
||||
/* New screen: profile, Wi-Fi, and the first flash in one window. */
|
||||
.link {
|
||||
|
||||
Binary file not shown.
@@ -6,6 +6,8 @@ the three icon fonts of both board profiles and builds the editor font
|
||||
fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# (group, ((MDI name, codepoint, label), ...)) in picker order.
|
||||
GROUPS = (
|
||||
('Lighting', (
|
||||
@@ -228,9 +230,141 @@ FIXED = (
|
||||
('lightbulb-off', 'F0E4F'),
|
||||
)
|
||||
|
||||
# Home Assistant's own default icons for the domains a tile or the top bar shows (frontend/get_icons, `entity_component`,
|
||||
# Home Assistant 2026.9), with their state, range and device class variants and the few its frontend picks in code: a
|
||||
# closed blind, a playing speaker, a battery at 40 %, a phone's tracker on the router (app 0.2.67). The firmware fonts carry them, so a tile shows the icon Home Assistant shows; a name missing here, such as
|
||||
# one a later Home Assistant adds, keeps the screen's own default. Not pickable in the editor.
|
||||
HA_DEFAULTS = (
|
||||
('account-arrow-right', 'F0B53'),
|
||||
('air-filter', 'F0D43'),
|
||||
('air-humidifier', 'F1099'),
|
||||
('air-humidifier-off', 'F1466'),
|
||||
('alert-circle', 'F0028'),
|
||||
('angle-acute', 'F0937'),
|
||||
('arrow-bottom-left', 'F0042'),
|
||||
('arrow-bottom-right', 'F0043'),
|
||||
('arrow-down-box', 'F06C0'),
|
||||
('arrow-left-right', 'F0E73'),
|
||||
('arrow-right', 'F0054'),
|
||||
('arrow-split-vertical', 'F093C'),
|
||||
('arrow-top-left', 'F005B'),
|
||||
('arrow-top-right', 'F005C'),
|
||||
('arrow-up-box', 'F06C3'),
|
||||
('audio-video', 'F093D'),
|
||||
('audio-video-off', 'F11B6'),
|
||||
('battery', 'F0079'),
|
||||
('battery-10', 'F007A'),
|
||||
('battery-20', 'F007B'),
|
||||
('battery-30', 'F007C'),
|
||||
('battery-40', 'F007D'),
|
||||
('battery-50', 'F007E'),
|
||||
('battery-60', 'F007F'),
|
||||
('battery-70', 'F0080'),
|
||||
('battery-80', 'F0081'),
|
||||
('battery-90', 'F0082'),
|
||||
('battery-alert', 'F0083'),
|
||||
('battery-charging', 'F0084'),
|
||||
('battery-outline', 'F008E'),
|
||||
('battery-unknown', 'F0091'),
|
||||
('blinds-horizontal', 'F1A2B'),
|
||||
('blinds-horizontal-closed', 'F1A2C'),
|
||||
('bluetooth', 'F00AF'),
|
||||
('bluetooth-connect', 'F00B1'),
|
||||
('brightness-5', 'F00DE'),
|
||||
('brightness-7', 'F00E0'),
|
||||
('button-pointer', 'F1B50'),
|
||||
('car-battery', 'F010C'),
|
||||
('car-coolant-level', 'F1019'),
|
||||
('cash', 'F0114'),
|
||||
('cast-connected', 'F0119'),
|
||||
('cast-off', 'F078A'),
|
||||
('check-circle', 'F05E0'),
|
||||
('check-circle-outline', 'F05E1'),
|
||||
('check-network-outline', 'F0C54'),
|
||||
('checkbox-marked-circle', 'F0133'),
|
||||
('circle-slice-8', 'F0AA5'),
|
||||
('clock', 'F0954'),
|
||||
('clock-start', 'F0155'),
|
||||
('close-circle-outline', 'F015A'),
|
||||
('close-network-outline', 'F0C5F'),
|
||||
('compass-rose', 'F1382'),
|
||||
('crop-portrait', 'F01A1'),
|
||||
('crosshairs-question', 'F1136'),
|
||||
('current-ac', 'F1480'),
|
||||
('database', 'F01BC'),
|
||||
('ear-hearing', 'F07C5'),
|
||||
('eye', 'F0208'),
|
||||
('eye-check', 'F0D04'),
|
||||
('fan-off', 'F081D'),
|
||||
('format-list-bulleted', 'F0279'),
|
||||
('garage-open', 'F06DA'),
|
||||
('gate', 'F0299'),
|
||||
('gate-open', 'F116A'),
|
||||
('home-outline', 'F06A1'),
|
||||
('lan-connect', 'F0318'),
|
||||
('lan-disconnect', 'F0319'),
|
||||
('lightning-bolt', 'F140B'),
|
||||
('lock-alert', 'F08EE'),
|
||||
('lock-clock', 'F097F'),
|
||||
('lock-open', 'F033F'),
|
||||
('molecule', 'F0BAC'),
|
||||
('molecule-co', 'F12FE'),
|
||||
('motion-sensor-off', 'F1435'),
|
||||
('music-note-off', 'F038A'),
|
||||
('octagon', 'F03C3'),
|
||||
('package', 'F03D3'),
|
||||
('package-up', 'F03D5'),
|
||||
('ph', 'F17C5'),
|
||||
('pipe-valve', 'F184D'),
|
||||
('power-plug-off', 'F06A6'),
|
||||
('progress-clock', 'F0996'),
|
||||
('projector-off', 'F1A23'),
|
||||
('radioactive', 'F043C'),
|
||||
('ray-vertex', 'F0445'),
|
||||
('roller-shade', 'F1A6B'),
|
||||
('script-text-play', 'F1727'),
|
||||
('security', 'F0483'),
|
||||
('shield', 'F0498'),
|
||||
('shield-airplane', 'F06BB'),
|
||||
('shield-lock', 'F099D'),
|
||||
('shield-moon', 'F1828'),
|
||||
('shield-off', 'F099E'),
|
||||
('shield-outline', 'F0499'),
|
||||
('sine-wave', 'F095B'),
|
||||
('smoke-detector-alert', 'F192E'),
|
||||
('smoke-detector-variant', 'F180B'),
|
||||
('smoke-detector-variant-alert', 'F1930'),
|
||||
('speaker-off', 'F04C4'),
|
||||
('speaker-pause', 'F1B73'),
|
||||
('speaker-play', 'F1B72'),
|
||||
('speedometer', 'F04C5'),
|
||||
('spoon-sugar', 'F1429'),
|
||||
('sprout-outline', 'F0E67'),
|
||||
('square', 'F0764'),
|
||||
('square-outline', 'F0763'),
|
||||
('storage-tank', 'F1A75'),
|
||||
('sun-wireless', 'F17FE'),
|
||||
('television-off', 'F083B'),
|
||||
('television-pause', 'F0F89'),
|
||||
('television-play', 'F0ECF'),
|
||||
('texture-box', 'F0FE6'),
|
||||
('thermometer-lines', 'F0510'),
|
||||
('toggle-switch-variant', 'F1A25'),
|
||||
('toggle-switch-variant-off', 'F1A26'),
|
||||
('transmission-tower', 'F0D3E'),
|
||||
('vibrate', 'F0566'),
|
||||
('water-boiler-off', 'F11B4'),
|
||||
('water-off', 'F058D'),
|
||||
('water-opacity', 'F1855'),
|
||||
('weight', 'F05A1'),
|
||||
('wifi', 'F05A9'),
|
||||
('window-closed', 'F05AE'),
|
||||
('window-open', 'F05B1'),
|
||||
)
|
||||
|
||||
# Pickable icons by name, and every glyph the firmware fonts contain.
|
||||
ICONS = {name: (codepoint, label) for _, icons in GROUPS for name, codepoint, label in icons}
|
||||
GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(FIXED)}
|
||||
GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(FIXED), **dict(HA_DEFAULTS)}
|
||||
|
||||
# Mirrors runtime_tiles::icon_for() so the editor mockup shows what the screen draws.
|
||||
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
|
||||
@@ -248,6 +382,97 @@ WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': '
|
||||
'lightning': 'weather-lightning', 'lightning-rainy': 'weather-lightning-rainy', 'windy': 'weather-windy',
|
||||
'windy-variant': 'weather-windy', 'exceptional': 'alert-circle-outline'}
|
||||
|
||||
# Domains whose icon the screen draws from the state itself: a weather condition, the sun above or below the horizon.
|
||||
OWN_ICON_DOMAINS = frozenset(('weather', 'sun', 'screen'))
|
||||
# Home Assistant's icon resources ({'entity_component': ..., 'entity': ...}), set by the app when it reads them; empty keeps
|
||||
# the tables of earlier releases.
|
||||
HA_ICONS = {}
|
||||
|
||||
def use_ha_icons(resources):
|
||||
"""The icon resources Home Assistant answered frontend/get_icons with, for every icon lookup of this app."""
|
||||
global HA_ICONS
|
||||
HA_ICONS = resources if isinstance(resources, dict) else {}
|
||||
|
||||
# A number as JavaScript's Number() reads a state: Home Assistant's frontend picks a range icon by it.
|
||||
JS_NUMBER = re.compile(r'[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Infinity')
|
||||
|
||||
def js_number(text):
|
||||
"""The number JavaScript's Number() reads from a state or a range key, None where it reads NaN (a blank text is 0)."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
return float(text.replace('Infinity', 'inf')) if JS_NUMBER.fullmatch(text) else None
|
||||
|
||||
def icon_for_state(state, spec):
|
||||
"""Home Assistant's getIconFromTranslations: the icon for this state, else the range's icon for a number at or above its
|
||||
lowest step (the highest step not above it), else the default."""
|
||||
if not isinstance(spec, dict):
|
||||
return None
|
||||
states, ranges = spec.get('state'), spec.get('range')
|
||||
if state and isinstance(states, dict) and states.get(state):
|
||||
return states[state]
|
||||
value = js_number(state) if isinstance(ranges, dict) else None
|
||||
if value is not None:
|
||||
steps = sorted(step for step in map(js_number, ranges) if step is not None)
|
||||
below = [step for step in steps if step <= value]
|
||||
if below:
|
||||
step = below[-1]
|
||||
icon = ranges.get(str(int(step)) if step.is_integer() else repr(step))
|
||||
if icon:
|
||||
return icon
|
||||
return spec.get('default')
|
||||
|
||||
def state_icon(domain, state, attributes):
|
||||
"""Home Assistant's stateIcon: the icons its frontend picks in code, before those of the integration."""
|
||||
if domain == 'update':
|
||||
return 'mdi:package-down' if attributes.get('in_progress') else 'mdi:package-up' if state == 'on' else 'mdi:package'
|
||||
if domain == 'device_tracker':
|
||||
source = attributes.get('source_type')
|
||||
if source == 'router':
|
||||
return 'mdi:lan-connect' if state == 'home' else 'mdi:lan-disconnect'
|
||||
if source in ('bluetooth', 'bluetooth_le'):
|
||||
return 'mdi:bluetooth-connect' if state == 'home' else 'mdi:bluetooth'
|
||||
return 'mdi:account-arrow-right' if state == 'not_home' else 'mdi:account'
|
||||
if domain == 'sun':
|
||||
return 'mdi:white-balance-sunny' if state == 'above_horizon' else 'mdi:weather-night'
|
||||
if domain == 'input_datetime':
|
||||
if not attributes.get('has_date'):
|
||||
return 'mdi:clock'
|
||||
if not attributes.get('has_time'):
|
||||
return 'mdi:calendar'
|
||||
return None
|
||||
|
||||
def ha_default_icon(entity_id, state, attributes, entry, icons):
|
||||
"""The icon Home Assistant's frontend shows for an entity without one of its own (frontend/get_icons, app 0.2.67), in
|
||||
its order: the integration's icon for the entity's translation key, then the icon its frontend picks in code, then the
|
||||
domain's icon for the state's device class or else the domain's. The MDI name, or None when Home Assistant has none;
|
||||
`state` None is an entity without a state, which gets the defaults."""
|
||||
if not isinstance(icons, dict):
|
||||
return None
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
attributes = attributes if isinstance(attributes, dict) else None
|
||||
icon = None
|
||||
if entry.get('platform') and entry.get('translation_key'):
|
||||
platform = ((icons.get('entity') or {}).get(entry['platform']) or {}).get(domain) or {}
|
||||
icon = icon_for_state(state, platform.get(entry['translation_key']))
|
||||
if not icon and attributes is not None and state is not None:
|
||||
icon = state_icon(domain, state, attributes)
|
||||
if not icon:
|
||||
component = (icons.get('entity_component') or {}).get(domain) or {}
|
||||
device_class = (attributes or {}).get('device_class')
|
||||
icon = icon_for_state(state, (isinstance(device_class, str) and component.get(device_class)) or component.get('_'))
|
||||
return icon[4:] if isinstance(icon, str) and icon.startswith('mdi:') else None
|
||||
|
||||
def default_glyph(entity_id, state, attributes, entry=None, icons=None):
|
||||
"""Codepoint of Home Assistant's default icon when the firmware fonts carry it; None keeps the screen's own icon."""
|
||||
if entity_id.split('.', 1)[0] in OWN_ICON_DOMAINS:
|
||||
return None
|
||||
name = ha_default_icon(entity_id, state, attributes, entry, HA_ICONS if icons is None else icons)
|
||||
return GLYPHS.get(name) if name else None
|
||||
|
||||
def ha_icon(attributes):
|
||||
"""Codepoint of Home Assistant's own `mdi:` icon when the firmware carries it, else None."""
|
||||
icon = attributes.get('icon') if isinstance(attributes, dict) else None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.66"
|
||||
version: "0.2.67"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -103,9 +103,15 @@ class Firmware(unittest.TestCase):
|
||||
def test_a_tap_on_a_cover_opens_the_cover_card(self):
|
||||
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
|
||||
event = event[:event.index('\n}\n')]
|
||||
show = re.search(r'if\(d=="sensor" \|\|.*?show_detail\(w\.index\); return; \}', event)
|
||||
self.assertTrue(show and 'd=="cover"' in show[0], 'covers go to show_detail with the other runtime cards')
|
||||
self.assertIn('if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {', event, 'not to the board\'s value overlay any more')
|
||||
# Since firmware 0.2.58 tile_controls::tap_route decides and event() carries the route out.
|
||||
controls = (ROOT / 'components/smart_display/tile_controls.h').read_text()
|
||||
cards = controls[controls.index('inline bool runtime_card_domain('):]
|
||||
cards = cards[:cards.index('\n}\n')]
|
||||
self.assertIn('d == "cover"', cards, 'covers go to show_detail with the other runtime cards')
|
||||
self.assertIn('if (runtime_card_domain(d)) return {TapRoute::CARD, "", true};', controls)
|
||||
self.assertIn('return d == "light" || d == "climate" || d == "fan" ? Tap{TapRoute::OVERLAY', controls, 'not to the board\'s value overlay any more')
|
||||
self.assertIn('case tile_controls::TapRoute::CARD:', event)
|
||||
self.assertIn('show_detail(w.index);', event)
|
||||
self.assertIn('}else if(d=="cover"){', RUNTIME)
|
||||
self.assertIn('render_cover_detail(t,large,width,height,pad);', RUNTIME)
|
||||
for name, text in PROFILES.items():
|
||||
|
||||
@@ -15,8 +15,10 @@ class ExtendedTests(unittest.TestCase):
|
||||
for domain in ['sensor','number','input_number','select','weather','media_player']:
|
||||
layout=validate_layout({'title':'Test','tiles':[{'entity':domain+'.x','options':{'display':'watch'}}]})
|
||||
self.assertEqual(layout['tiles'][0]['options']['display'],'watch')
|
||||
for options in [{'inline':'slider'},{'tap':'toggle'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
|
||||
for options in [{'inline':'slider'},{'history_hours':True},{'history_hours':100},{'arbitrary':'run'}]:
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
|
||||
# On / off: whether Home Assistant offers <domain>.toggle is checked when saving (app 0.2.67); a built-in card never.
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'screen.clock','options':{'tap':'toggle'}}]})
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
|
||||
def test_attributes_and_history_fit_protocol(self):
|
||||
tile={'entity':'sensor.a','name':'Temperature','options':{'history_hours':24,'display':'watch'}}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""What Home Assistant says an entity can do (app 0.2.67): the editor offers On / off, a small slider, direct controls
|
||||
and displays from Home Assistant's own action list, and a save refuses a new setting Home Assistant doesn't support.
|
||||
|
||||
SERVICES are real action descriptions (`get_services` of Home Assistant 2026.9.2, trimmed to what the tests use); the
|
||||
entities carry the supported_features of real devices. On that installation `local_actions` gave the same answer as
|
||||
Home Assistant's `get_services_for_target` for 30 entities out of 30."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import ha_catalogue # noqa: E402
|
||||
from core import validate_layout # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import HomeAssistant, Manager, Refused, create_app
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
SERVICES = json.loads('''{"cover":{"toggle":{"target":{"entity":[{"domain":["cover"],"supported_features":[3]}]}},
|
||||
"open_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[1]}]}},"close_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[2]}]}},
|
||||
"stop_cover":{"target":{"entity":[{"domain":["cover"],"supported_features":[8]}]}},"set_cover_position":{"target":{"entity":[{"domain":["cover"],"supported_features":[4]}]}}},
|
||||
"media_player":{"toggle":{"target":{"entity":[{"domain":["media_player"],"supported_features":[384]}]}},"turn_on":{"target":{"entity":[{"domain":["media_player"],"supported_features":[128]}]}},
|
||||
"turn_off":{"target":{"entity":[{"domain":["media_player"],"supported_features":[256]}]}},"volume_set":{"target":{"entity":[{"domain":["media_player"],"supported_features":[4]}]}},
|
||||
"volume_mute":{"target":{"entity":[{"domain":["media_player"],"supported_features":[8]}]}},"media_play_pause":{"target":{"entity":[{"domain":["media_player"],"supported_features":[16385]}]}},
|
||||
"media_next_track":{"target":{"entity":[{"domain":["media_player"],"supported_features":[32]}]}},"media_previous_track":{"target":{"entity":[{"domain":["media_player"],"supported_features":[16]}]}}},
|
||||
"light":{"toggle":{"target":{"entity":[{"domain":["light"]}]}},"turn_on":{"target":{"entity":[{"domain":["light"]}]},"fields":{"brightness_pct":{"filter":{"attribute":{"supported_color_modes":["brightness","color_temp","hs","xy","rgb","rgbw","rgbww"]}},"selector":{"number":{"min":0.0,"max":100.0,"unit_of_measurement":"%","step":1.0,"mode":"slider"}}},"additional_fields":{"collapsed":true,"fields":{"white":{"filter":{"attribute":{"supported_color_modes":["white"]}}}}}}},
|
||||
"turn_off":{"target":{"entity":[{"domain":["light"]}]}}},
|
||||
"climate":{"toggle":{"target":{"entity":[{"domain":["climate"],"supported_features":[128,256]}]}},"set_temperature":{"target":{"entity":[{"domain":["climate"],"supported_features":[1,2]}]}},"set_hvac_mode":{"target":{"entity":[{"domain":["climate"]}]}}},
|
||||
"homeassistant":{"toggle":{"target":{}},"restart":{}},"sonos":{"snapshot":{"target":{"entity":[{"integration":"sonos","domain":["media_player"]}]}}},
|
||||
"scene":{"turn_on":{"target":{"entity":[{"domain":["scene"]}]}},"delete":{"target":{"entity":[{"integration":"homeassistant","domain":["scene"]}]}}},
|
||||
"script":{"turn_on":{"target":{"entity":[{"domain":["script"]}]}},"toggle":{"target":{"entity":[{"domain":["script"]}]}}},
|
||||
"fan":{"toggle":{"target":{"entity":[{"domain":["fan"]}]}},"set_percentage":{"target":{"entity":[{"domain":["fan"],"supported_features":[1]}]}}},
|
||||
"weather":{"get_forecasts":{"target":{"entity":[{"domain":["weather"],"supported_features":[1,2,4]}]},"response":{"optional":false}}},
|
||||
"switch":{"toggle":{"target":{"entity":[{"domain":["switch"]}]}}},"notify":{"send_message":{"target":{"entity":[{"domain":["notify"]}]}}}}''')
|
||||
|
||||
# entity: (state, attributes, integration)
|
||||
ENTITIES = {
|
||||
'media_player.sonos': ('idle', {'supported_features': 8321599, 'device_class': 'speaker', 'friendly_name': 'Bathroom'}, 'sonos'),
|
||||
'media_player.tv': ('off', {'supported_features': 24509, 'device_class': 'tv'}, 'webostv'),
|
||||
'cover.curtains': ('open', {'supported_features': 15, 'device_class': 'curtain', 'current_position': 100}, 'zha'),
|
||||
'cover.garage': ('closed', {'supported_features': 3, 'device_class': 'garage'}, 'demo'),
|
||||
'cover.position_only': ('open', {'supported_features': 4}, 'demo'),
|
||||
'light.hood': ('off', {'supported_features': 0, 'supported_color_modes': ['onoff']}, 'switch_as_x'),
|
||||
'light.bathroom': ('on', {'supported_features': 44, 'supported_color_modes': ['color_temp', 'xy']}, 'zha'),
|
||||
'climate.airco': ('cool', {'supported_features': 425, 'hvac_modes': ['off', 'cool']}, 'esphome'),
|
||||
'climate.old_thermostat': ('heat', {'supported_features': 1}, 'demo'),
|
||||
'scene.evening': ('2026-09-17T08:00:00+00:00', {}, 'homeassistant'),
|
||||
'script.wake_up': ('off', {}, 'script'),
|
||||
'fan.ceiling': ('off', {'supported_features': 0}, 'demo'),
|
||||
'sensor.temperature': ('21.456', {'unit_of_measurement': '°C', 'state_class': 'measurement'}, 'demo'),
|
||||
'sensor.washer': ('rinsing', {'device_class': 'enum'}, 'demo'),
|
||||
'weather.buienradar': ('rainy', {'supported_features': 1}, 'buienradar'),
|
||||
'weather.hourly_only': ('rainy', {'supported_features': 2}, 'demo'),
|
||||
}
|
||||
|
||||
|
||||
def state(entity):
|
||||
value, attributes, _ = ENTITIES[entity]
|
||||
return {'entity_id': entity, 'state': value, 'attributes': attributes}
|
||||
|
||||
|
||||
def actions(entity):
|
||||
_, attributes, platform = ENTITIES[entity]
|
||||
return ha_catalogue.local_actions(SERVICES, entity, attributes, platform)
|
||||
|
||||
|
||||
def caps(entity):
|
||||
return ha_catalogue.capabilities(entity, actions(entity), state(entity), SERVICES)
|
||||
|
||||
|
||||
class ActionsForAnEntity(unittest.TestCase):
|
||||
def test_home_assistants_target_filters(self):
|
||||
# Every mask of an action must be supported completely: a Sonos lacks on and off, a TV has both.
|
||||
self.assertNotIn('media_player.toggle', actions('media_player.sonos'))
|
||||
self.assertIn('media_player.toggle', actions('media_player.tv'))
|
||||
self.assertEqual([a for a in actions('cover.curtains') if a.startswith('cover.')],
|
||||
['cover.close_cover', 'cover.open_cover', 'cover.set_cover_position', 'cover.stop_cover', 'cover.toggle'])
|
||||
self.assertIn('cover.toggle', actions('cover.garage'))
|
||||
self.assertNotIn('cover.toggle', actions('cover.position_only'))
|
||||
# Either mask of climate.toggle will do.
|
||||
self.assertIn('climate.toggle', actions('climate.airco'))
|
||||
self.assertNotIn('climate.toggle', actions('climate.old_thermostat'))
|
||||
# An integration filter: only a Sonos gets sonos.snapshot; only scenes of Home Assistant itself can be deleted.
|
||||
self.assertIn('sonos.snapshot', actions('media_player.sonos'))
|
||||
self.assertNotIn('sonos.snapshot', actions('media_player.tv'))
|
||||
self.assertIn('scene.delete', actions('scene.evening'))
|
||||
# A target without an entity filter takes every entity; an action without a target is no entity action.
|
||||
self.assertIn('homeassistant.toggle', actions('sensor.washer'))
|
||||
self.assertNotIn('homeassistant.restart', actions('light.hood'))
|
||||
self.assertNotIn('notify.send_message', actions('light.hood'))
|
||||
|
||||
def test_fields_follow_their_filter_also_inside_a_section(self):
|
||||
turn_on = SERVICES['light']['turn_on']
|
||||
self.assertFalse(ha_catalogue.field_matches(ha_catalogue.find_field(turn_on, 'brightness_pct'), {'supported_color_modes': ['onoff']}))
|
||||
self.assertTrue(ha_catalogue.field_matches(ha_catalogue.find_field(turn_on, 'brightness_pct'), {'supported_color_modes': ['xy']}))
|
||||
white = ha_catalogue.find_field(turn_on, 'white')
|
||||
self.assertIsNotNone(white, 'fields in a section (additional_fields) count too')
|
||||
self.assertTrue(ha_catalogue.field_matches(white, {'supported_color_modes': ['white', 'xy']}))
|
||||
self.assertTrue(ha_catalogue.field_matches({'selector': {}}, {}), 'a field without a filter is always there')
|
||||
|
||||
|
||||
class WhatTheEditorOffers(unittest.TestCase):
|
||||
def test_on_off_follows_home_assistant(self):
|
||||
self.assertFalse(caps('media_player.sonos')['toggle'])
|
||||
self.assertTrue(caps('media_player.tv')['toggle'])
|
||||
self.assertTrue(caps('cover.curtains')['toggle'], 'issue #7: a cover taps open and closed')
|
||||
self.assertTrue(caps('script.wake_up')['toggle'])
|
||||
self.assertFalse(caps('scene.evening')['toggle'])
|
||||
self.assertFalse(caps('sensor.temperature')['toggle'])
|
||||
|
||||
def test_small_slider_and_direct_controls(self):
|
||||
self.assertEqual(caps('light.hood'), {'toggle': True, 'inline': False, 'controls': ['toggle'], 'displays': ['standard', 'watch']})
|
||||
self.assertEqual((caps('light.bathroom')['inline'], caps('light.bathroom')['controls']), (True, ['toggle', 'brightness']))
|
||||
self.assertEqual((caps('cover.curtains')['inline'], caps('cover.curtains')['controls']), (True, ['buttons', 'position']))
|
||||
self.assertEqual((caps('cover.garage')['inline'], caps('cover.garage')['controls']), (False, ['buttons']))
|
||||
self.assertEqual((caps('cover.position_only')['inline'], caps('cover.position_only')['controls']), (True, ['position']))
|
||||
self.assertEqual(caps('media_player.sonos')['controls'], ['volume', 'playback'])
|
||||
self.assertEqual(caps('climate.old_thermostat')['controls'], ['setpoint', 'mode'])
|
||||
self.assertEqual((caps('fan.ceiling')['inline'], caps('fan.ceiling')['controls']), (False, ['toggle']))
|
||||
self.assertEqual(caps('scene.evening')['controls'], ['run'])
|
||||
|
||||
def test_displays(self):
|
||||
self.assertIn('graph', caps('sensor.temperature')['displays'])
|
||||
self.assertNotIn('graph', caps('sensor.washer')['displays'])
|
||||
self.assertIn('forecast', caps('weather.buienradar')['displays'])
|
||||
self.assertNotIn('forecast', caps('weather.hourly_only')['displays'])
|
||||
unavailable = {'entity_id': 'weather.x', 'state': 'unavailable', 'attributes': {}}
|
||||
self.assertIn('forecast', ha_catalogue.capabilities('weather.x', [], unavailable, SERVICES)['displays'])
|
||||
|
||||
|
||||
class NewSettingsOnly(unittest.TestCase):
|
||||
def test_a_new_setting_home_assistant_lacks_is_refused_a_saved_one_stays(self):
|
||||
sonos = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(sonos, None, caps('media_player.sonos')), ('tap', 'toggle'))
|
||||
before = {'entity': 'media_player.sonos', 'options': {'tap': 'auto'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(sonos, before, caps('media_player.sonos')), ('tap', 'toggle'))
|
||||
saved = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle', 'background': 'blue'}}
|
||||
changed = {'entity': 'media_player.sonos', 'options': {'tap': 'toggle', 'background': 'red'}}
|
||||
self.assertIsNone(ha_catalogue.unsupported(changed, saved, caps('media_player.sonos')), 'a setting the tile already had stays')
|
||||
self.assertIsNone(ha_catalogue.unsupported(sonos, None, None), 'nothing is refused while Home Assistant cannot say')
|
||||
curtain = {'entity': 'cover.curtains', 'options': {'tap': 'toggle', 'inline': 'slider'}}
|
||||
self.assertIsNone(ha_catalogue.unsupported(curtain, None, caps('cover.curtains')))
|
||||
hood = {'entity': 'light.hood', 'options': {'inline': 'slider'}}
|
||||
self.assertEqual(ha_catalogue.unsupported(hood, None, caps('light.hood')), ('inline', 'slider'))
|
||||
self.assertEqual(ha_catalogue.unsupported({'entity': 'cover.garage', 'options': {'size': 'wide', 'controls': 'position'}}, None,
|
||||
caps('cover.garage')), ('controls', 'position'))
|
||||
self.assertEqual(ha_catalogue.unsupported({'entity': 'sensor.washer', 'options': {'display': 'graph'}}, None, caps('sensor.washer')),
|
||||
('display', 'graph'))
|
||||
self.assertIn("can't turn Bathroom on and off", ha_catalogue.refusal('media_player.sonos', 'Bathroom', 'tap', 'toggle'))
|
||||
|
||||
def test_a_setting_a_newer_app_saved_never_stops_the_app(self):
|
||||
stored = {'title': 'Hall', 'tiles': [{'entity': 'cover.curtains', 'name': '', 'slot': 0,
|
||||
'options': {'tap': 'swipe', 'swipe': {'action': 'cover.toggle'}}}]}
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout(stored)
|
||||
kept = validate_layout(stored, stored=True)
|
||||
self.assertEqual(kept['tiles'][0]['options'], stored['tiles'][0]['options'])
|
||||
with self.assertRaises(ValueError, msg='a broken entity still stops loading'):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'nonsense', 'options': {}}]}, stored=True)
|
||||
|
||||
|
||||
def fake_ha(target_lookup=True):
|
||||
class HA(HomeAssistant):
|
||||
def __init__(self):
|
||||
super().__init__(None, 'http://supervisor/core/api', 'token')
|
||||
self.registry = [{'entity_id': 'text.d1_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.d1_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
self.states = {'text.d1_tiles': {'state': 'Synced'}, 'sensor.d1_node': {'state': 'living-room'}, 'sensor.d1_fw': {'state': '0.2.58'}}
|
||||
for entity, (_, attributes, platform) in ENTITIES.items():
|
||||
self.registry.append({'entity_id': entity, 'platform': platform, 'device_id': 'd9'})
|
||||
self.states[entity] = state(entity)
|
||||
self.devices, self.areas = [{'id': 'd1', 'name': 'Living room screen'}, {'id': 'd9', 'name': 'Demo'}], []
|
||||
self.services, self.services_rev = SERVICES, 1
|
||||
self.asked, self.fired, self.published = [], [], {}
|
||||
|
||||
async def request(self, kind, **data):
|
||||
self.asked.append((kind, data))
|
||||
if kind != 'get_services_for_target':
|
||||
raise AssertionError(kind)
|
||||
if not target_lookup:
|
||||
raise Refused('Unknown command.')
|
||||
entity = data['target']['entity_id'][0]
|
||||
return ha_catalogue.local_actions(SERVICES, entity, self.states[entity]['attributes'], ENTITIES[entity][2])
|
||||
|
||||
async def fire(self, event_type, data):
|
||||
self.fired.append((event_type, data))
|
||||
|
||||
async def set_state(self, entity_id, value, attributes):
|
||||
self.published[entity_id] = (value, attributes)
|
||||
return HA()
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class AskingHomeAssistant(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_home_assistant_answers_and_the_answer_is_kept_per_entity(self):
|
||||
ha = fake_ha()
|
||||
self.assertFalse((await ha.capabilities('media_player.sonos'))['toggle'])
|
||||
self.assertTrue((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
await ha.capabilities('cover.curtains')
|
||||
self.assertEqual([data['target'] for _, data in ha.asked], [{'entity_id': ['media_player.sonos']}, {'entity_id': ['cover.curtains']}])
|
||||
# New features, a new action list or a new registry ask again.
|
||||
ha.states['cover.curtains'] = {**state('cover.curtains'), 'attributes': {'supported_features': 4}}
|
||||
self.assertFalse((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
ha.services_rev += 1
|
||||
await ha.capabilities('cover.curtains')
|
||||
self.assertEqual(len(ha.asked), 4)
|
||||
self.assertIsNone(await ha.capabilities('light.unknown'), 'no state: unknown, so nothing is refused')
|
||||
|
||||
async def test_an_older_home_assistant_gets_the_same_answer_from_the_descriptions(self):
|
||||
ha = fake_ha(target_lookup=False)
|
||||
self.assertFalse((await ha.capabilities('media_player.sonos'))['toggle'])
|
||||
self.assertTrue((await ha.capabilities('cover.curtains'))['toggle'])
|
||||
self.assertEqual(len(ha.asked), 1, 'asked once; after "Unknown command" the descriptions answer')
|
||||
self.assertIs(ha.target_lookup, False)
|
||||
|
||||
async def test_no_action_list_yet_means_unknown(self):
|
||||
ha = fake_ha()
|
||||
ha.services = {}
|
||||
self.assertIsNone(await ha.capabilities('cover.curtains'))
|
||||
self.assertEqual(ha.asked, [])
|
||||
|
||||
async def test_saving_and_tile_events_refuse_only_new_settings(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
m = Manager(fake_ha(), path)
|
||||
tiles = [{'entity': 'cover.curtains', 'name': '', 'slot': 0, 'options': {'tap': 'toggle'}},
|
||||
{'entity': 'media_player.sonos', 'name': '', 'slot': 1}]
|
||||
data = {'title': 'Living room', 'tiles': tiles}
|
||||
await m.check_supported('text.d1_tiles', data)
|
||||
m.save('text.d1_tiles', data)
|
||||
refused = {'title': 'Living room', 'tiles': [tiles[0], {**tiles[1], 'options': {'tap': 'toggle'}}]}
|
||||
with self.assertRaisesRegex(ValueError, "can't turn Bathroom on and off"):
|
||||
await m.check_supported('text.d1_tiles', refused)
|
||||
# A layout saved before 0.2.67 with On / off on that speaker keeps saving when something else changes.
|
||||
m.layouts['text.d1_tiles']['tiles'][1]['options'] = {'tap': 'toggle'}
|
||||
await m.check_supported('text.d1_tiles', {'title': 'Renamed', 'tiles': [tiles[0], {**tiles[1], 'options': {'tap': 'toggle'}}]})
|
||||
# Claude in Home Assistant gets the same answer.
|
||||
m.ha.tile_events = asyncio.Queue()
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'light.hood', 'inline': 'slider'}))
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.garage', 'tap': 'toggle'}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(m.ha.fired) == 2:
|
||||
break
|
||||
worker.cancel()
|
||||
answers = [answer for _, answer in m.ha.fired]
|
||||
self.assertFalse(answers[0]['ok'])
|
||||
self.assertIn('small slider', answers[0]['error'])
|
||||
self.assertTrue(answers[1]['ok'], answers[1])
|
||||
saved = json.loads(path.read_text())['screens']['text.d1_tiles']
|
||||
self.assertEqual({t['entity']: t.get('options', {}).get('tap') for t in saved['tiles']},
|
||||
{'cover.curtains': 'toggle', 'media_player.sonos': 'toggle', 'cover.garage': 'toggle'})
|
||||
|
||||
async def test_the_editor_asks_for_several_entities_at_once(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
answer = await (await client.get('/api/capabilities?entity=cover.curtains&entity=media_player.sonos&entity=light.unknown')).json()
|
||||
found = answer['capabilities']
|
||||
self.assertTrue(found['cover.curtains']['toggle'])
|
||||
self.assertFalse(found['media_player.sonos']['toggle'])
|
||||
self.assertIsNone(found['light.unknown'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Home Assistant's default icons on tiles and in the top bar (app 0.2.67, firmware 0.2.58). ICONS is a subset of what
|
||||
Home Assistant 2026.9.2 answers frontend/get_icons with; the firmware fonts carry the icons Home Assistant uses for the
|
||||
domains a screen shows, so a closed blind, a playing speaker and a door that is open look as they do in Home Assistant."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import tile_icons # noqa: E402
|
||||
import header_bar # noqa: E402
|
||||
from core import discover, screen_options # noqa: E402
|
||||
|
||||
ICONS = json.loads('''{"entity_component":{"cover":{"_":{"default":"mdi:window-open","state":{"closed":"mdi:window-closed","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}},"blind":{"default":"mdi:blinds-horizontal","state":{"closed":"mdi:blinds-horizontal-closed","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}},"garage":{"default":"mdi:garage-open","state":{"closed":"mdi:garage","closing":"mdi:arrow-down-box","opening":"mdi:arrow-up-box"}}},"light":{"_":{"default":"mdi:lightbulb","state":{"off":"mdi:lightbulb-off"}}},"climate":{"_":{"default":"mdi:thermostat"}},"scene":{"_":{"default":"mdi:palette"}},"script":{"_":{"default":"mdi:script-text","state":{"on":"mdi:script-text-play"}}},"binary_sensor":{"_":{"default":"mdi:radiobox-blank","state":{"on":"mdi:checkbox-marked-circle"}},"door":{"default":"mdi:door-closed","state":{"on":"mdi:door-open"}},"smoke":{"default":"mdi:smoke-detector-variant","state":{"on":"mdi:smoke-detector-variant-alert"}}},"sensor":{"_":{"default":"mdi:eye"},"temperature":{"default":"mdi:thermometer"},"battery":{"default":"mdi:battery-unknown","range":{"0":"mdi:battery-alert","10":"mdi:battery-10","20":"mdi:battery-20","30":"mdi:battery-30","40":"mdi:battery-40","50":"mdi:battery-50","60":"mdi:battery-60","70":"mdi:battery-70","80":"mdi:battery-80","90":"mdi:battery-90","100":"mdi:battery"}},"wind_direction":{"default":"mdi:compass-rose","range":{"0":"mdi:arrow-down","22.5":"mdi:arrow-bottom-left","67.5":"mdi:arrow-left","112.5":"mdi:arrow-top-left","157.5":"mdi:arrow-up","202.5":"mdi:arrow-top-right","247.5":"mdi:arrow-right","292.5":"mdi:arrow-bottom-right","337.5":"mdi:arrow-down"}}},"device_tracker":{"_":{"default":"mdi:account","state":{"not_home":"mdi:account-arrow-right"}}},"update":{"_":{"default":"mdi:package-up","state":{"off":"mdi:package"}}},"media_player":{"_":{"default":"mdi:cast","state":{"off":"mdi:cast-off","paused":"mdi:cast-connected","playing":"mdi:cast-connected"}},"speaker":{"default":"mdi:speaker","state":{"off":"mdi:speaker-off","paused":"mdi:speaker-pause","playing":"mdi:speaker-play"}},"tv":{"default":"mdi:television","state":{"off":"mdi:television-off","paused":"mdi:television-pause","playing":"mdi:television-play"}}},"lock":{"_":{"default":"mdi:lock","state":{"jammed":"mdi:lock-alert","locking":"mdi:lock-clock","open":"mdi:lock-open-variant","opening":"mdi:lock-clock","unlocked":"mdi:lock-open-variant","unlocking":"mdi:lock-clock"}}},"weather":{"_":{"default":"mdi:weather-partly-cloudy","state":{"rainy":"mdi:weather-rainy"}}}},"entity":{"zha":{"binary_sensor":{"hand_open":{"default":"mdi:hand-wave"}}}}}''')
|
||||
|
||||
|
||||
def glyph(name):
|
||||
return tile_icons.GLYPHS[name]
|
||||
|
||||
|
||||
class Resolution(unittest.TestCase):
|
||||
def test_as_home_assistants_frontend_picks_it(self):
|
||||
name = lambda entity, state, attributes=None, entry=None: tile_icons.ha_default_icon(entity, state, attributes or {}, entry, ICONS)
|
||||
self.assertEqual(name('cover.office', 'closed', {'device_class': 'blind'}), 'blinds-horizontal-closed')
|
||||
self.assertEqual(name('cover.office', 'open', {'device_class': 'blind'}), 'blinds-horizontal')
|
||||
self.assertEqual(name('cover.skylight', 'opening'), 'arrow-up-box', 'a class without an entry uses the domain')
|
||||
self.assertEqual(name('light.lamp', 'off'), 'lightbulb-off')
|
||||
self.assertEqual(name('media_player.sonos', 'playing', {'device_class': 'speaker'}), 'speaker-play')
|
||||
self.assertEqual(name('binary_sensor.front', 'on', {'device_class': 'door'}), 'door-open')
|
||||
self.assertEqual(name('sensor.hall', '21.5', {}, {'original_device_class': 'temperature'}), 'eye',
|
||||
"the frontend reads the device class from the state, which carries the registry's")
|
||||
self.assertEqual(name('cover.gate', 'opening', {'device_class': 'gate'}), 'arrow-up-box')
|
||||
self.assertEqual(name('binary_sensor.hand', 'on', {}, {'platform': 'zha', 'translation_key': 'hand_open'}), 'hand-wave',
|
||||
"an integration's own icon comes first")
|
||||
self.assertIsNone(tile_icons.ha_default_icon('cover.x', 'open', {}, {}, {}))
|
||||
self.assertEqual(name('cover.x', None), 'window-open', 'an entity without a state gets the default, as in the entity list')
|
||||
|
||||
def test_a_number_picks_its_step_of_the_range(self):
|
||||
name = lambda entity, state, device_class: tile_icons.ha_default_icon(entity, state, {'device_class': device_class}, None, ICONS)
|
||||
for level, icon in (('45', 'battery-40'), ('40', 'battery-40'), ('9.9', 'battery-alert'), ('5', 'battery-alert'),
|
||||
('100', 'battery'), ('250', 'battery'), (' 71 ', 'battery-70'), ('-1', 'battery-unknown'),
|
||||
('unavailable', 'battery-unknown'), ('unknown', 'battery-unknown')):
|
||||
self.assertEqual(name('sensor.phone_battery', level, 'battery'), icon, level)
|
||||
for degrees, icon in (('0', 'arrow-down'), ('22.5', 'arrow-bottom-left'), ('100', 'arrow-left'), ('350', 'arrow-down'),
|
||||
('2.1e2', 'arrow-top-right'), ('north', 'compass-rose')):
|
||||
self.assertEqual(name('sensor.wind', degrees, 'wind_direction'), icon, degrees)
|
||||
self.assertEqual(tile_icons.js_number(''), 0.0, 'Number("") is 0')
|
||||
self.assertIsNone(tile_icons.js_number('0x1'), 'not a decimal state')
|
||||
|
||||
def test_the_icons_the_frontend_picks_in_code(self):
|
||||
name = lambda entity, state, attributes: tile_icons.ha_default_icon(entity, state, attributes, None, ICONS)
|
||||
self.assertEqual(name('device_tracker.phone', 'home', {'source_type': 'router'}), 'lan-connect')
|
||||
self.assertEqual(name('device_tracker.phone', 'not_home', {'source_type': 'router'}), 'lan-disconnect')
|
||||
self.assertEqual(name('device_tracker.tag', 'home', {'source_type': 'bluetooth_le'}), 'bluetooth-connect')
|
||||
self.assertEqual(name('device_tracker.tag', 'not_home', {'source_type': 'bluetooth'}), 'bluetooth')
|
||||
self.assertEqual(name('device_tracker.car', 'not_home', {'source_type': 'gps'}), 'account-arrow-right')
|
||||
self.assertEqual(name('device_tracker.car', 'Work', {'source_type': 'gps'}), 'account')
|
||||
self.assertEqual(name('input_datetime.alarm', '07:00:00', {'has_date': False, 'has_time': True}), 'clock')
|
||||
self.assertEqual(name('input_datetime.trip', '2026-09-20', {'has_date': True, 'has_time': False}), 'calendar')
|
||||
self.assertIsNone(name('input_datetime.moment', '2026-09-20 07:00:00', {'has_date': True, 'has_time': True}),
|
||||
'Home Assistant has no icons of its own for input_datetime')
|
||||
self.assertEqual(name('update.core', 'off', {'in_progress': True}), 'package-down')
|
||||
self.assertEqual(name('update.core', 'on', {'in_progress': False}), 'package-up')
|
||||
platform = {'entity': {'demo': {'device_tracker': {'phone': {'default': 'mdi:cellphone'}}}}}
|
||||
self.assertEqual(tile_icons.ha_default_icon('device_tracker.phone', 'home', {'source_type': 'router'},
|
||||
{'platform': 'demo', 'translation_key': 'phone'}, platform), 'cellphone',
|
||||
"an integration's own icon comes before the frontend's")
|
||||
|
||||
def test_the_screen_gets_only_what_its_fonts_carry(self):
|
||||
glyph_of = lambda entity, state, attributes=None, entry=None: tile_icons.default_glyph(entity, state, attributes or {}, entry, ICONS)
|
||||
self.assertEqual(glyph_of('cover.office', 'closed', {'device_class': 'blind'}), glyph('blinds-horizontal-closed'))
|
||||
self.assertEqual(glyph_of('climate.airco', 'cool'), glyph('thermostat'))
|
||||
self.assertEqual(glyph_of('scene.evening', '2026-09-17T08:00:00+00:00'), glyph('palette'))
|
||||
self.assertEqual(glyph_of('script.wake', 'on'), glyph('script-text-play'))
|
||||
self.assertEqual(glyph_of('sensor.phone_battery', '64', {'device_class': 'battery'}), glyph('battery-60'))
|
||||
self.assertEqual(glyph_of('device_tracker.phone', 'home', {'source_type': 'router'}), glyph('lan-connect'))
|
||||
self.assertIsNone(glyph_of('update.core', 'off', {'in_progress': True}), 'package-down: no update tiles yet')
|
||||
self.assertIsNone(glyph_of('binary_sensor.hand', 'on', {}, {'platform': 'zha', 'translation_key': 'hand_open'}),
|
||||
'hand-wave is not in the fonts: the screen keeps its own icon')
|
||||
self.assertIsNone(glyph_of('binary_sensor.plain', 'off'), 'radiobox-blank is not in the icon font either')
|
||||
for entity in ('weather.home', 'sun.sun', 'screen.clock'):
|
||||
self.assertIsNone(glyph_of(entity, 'rainy'), 'the screen draws these from the state itself')
|
||||
|
||||
def test_every_default_icon_of_home_assistant_for_these_domains_is_in_the_fonts(self):
|
||||
wanted = set()
|
||||
for spec in ICONS['entity_component'].values():
|
||||
for entry in spec.values():
|
||||
wanted.add(entry['default'][4:])
|
||||
wanted.update(icon[4:] for icon in (entry.get('state') or {}).values())
|
||||
wanted.update(icon[4:] for icon in (entry.get('range') or {}).values())
|
||||
wanted.update(('lan-connect', 'lan-disconnect', 'bluetooth-connect', 'bluetooth', 'account-arrow-right', 'account', 'clock', 'calendar'))
|
||||
self.assertEqual(sorted(wanted - set(tile_icons.GLYPHS)), ['radiobox-blank'], 'the one icon the MDI font of this repo lacks')
|
||||
self.assertFalse(set(dict(tile_icons.HA_DEFAULTS)) & set(tile_icons.ICONS), 'the defaults are not pickable')
|
||||
|
||||
|
||||
class Everywhere(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tile_icons.use_ha_icons(ICONS)
|
||||
|
||||
def tearDown(self):
|
||||
tile_icons.use_ha_icons({})
|
||||
|
||||
def test_tile_top_bar_and_editor_show_the_same_icon(self):
|
||||
state = {'state': 'closed', 'attributes': {'device_class': 'blind', 'friendly_name': 'Office blind'}}
|
||||
auto = {'entity': 'cover.office', 'name': ''}
|
||||
self.assertEqual(screen_options(auto, state['attributes'], 'closed')['icon'], glyph('blinds-horizontal-closed'))
|
||||
chosen = {'entity': 'cover.office', 'name': '', 'options': {'icon': 'lamp'}}
|
||||
self.assertEqual(screen_options(chosen, state['attributes'], 'closed')['icon'], tile_icons.ICONS['lamp'][0], 'a chosen icon wins')
|
||||
own = {**state['attributes'], 'icon': 'mdi:curtains'}
|
||||
self.assertEqual(screen_options(auto, own, 'closed')['icon'], glyph('curtains'), "an entity's own icon wins")
|
||||
self.assertEqual(header_bar.auto_icon('cover.office', state), glyph('blinds-horizontal-closed'))
|
||||
_, entities = discover([{'entity_id': 'cover.office', 'platform': 'demo'}], {'cover.office': {**state, 'entity_id': 'cover.office'}}, [], [])
|
||||
self.assertEqual(entities[0]['icon'], glyph('blinds-horizontal-closed'))
|
||||
self.assertEqual(header_bar.auto_icon('weather.home', {'state': 'rainy', 'attributes': {}}), glyph('weather-rainy'))
|
||||
|
||||
def test_without_home_assistants_icons_everything_is_as_before(self):
|
||||
tile_icons.use_ha_icons({})
|
||||
self.assertNotIn('icon', screen_options({'entity': 'cover.office', 'name': ''}, {'device_class': 'blind'}, 'closed') or {})
|
||||
self.assertEqual(header_bar.auto_icon('climate.airco', {'state': 'cool', 'attributes': {}}), glyph('thermostat'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -38,6 +38,14 @@ static void test_domains_and_packing() {
|
||||
assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max"));
|
||||
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Home", changed));
|
||||
assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state
|
||||
// A scene or button that never ran is unknown in Home Assistant and can still be pressed; other unknown states can't act.
|
||||
for (const char *entity : {"scene.evening", "button.restart", "input_button.doorbell"}) {
|
||||
runtime_tiles::Tile never; never.entity = entity; never.received = true; never.state = "unknown";
|
||||
assert(never.available());
|
||||
never.state = "unavailable"; assert(!never.available());
|
||||
}
|
||||
{ runtime_tiles::Tile lamp; lamp.entity = "light.lamp"; lamp.received = true; lamp.state = "unknown"; assert(!lamp.available()); }
|
||||
{ runtime_tiles::Tile waiting; waiting.entity = "scene.evening"; waiting.state = "unknown"; assert(!waiting.available()); }
|
||||
m.tiles[4].state = "home"; assert(m.tiles[4].active());
|
||||
m.tiles[5].state = "active"; assert(m.tiles[5].active());
|
||||
m.tiles[5].state = "idle"; assert(!m.tiles[5].active());
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Home Assistant's numbers and words (app 0.2.67, firmware 0.2.58): a sensor's value rounded by its display precision,
|
||||
and Home Assistant's word where a tile, the top bar, a history timeline or a vacuum card's chip showed the raw state
|
||||
("open", "playing", "rinsing", "vac_and_mop"). WORDS are real entries of frontend/get_translations (English) of Home
|
||||
Assistant 2026.9.2."""
|
||||
from datetime import timezone
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import ha_catalogue # noqa: E402
|
||||
import header_bar # noqa: E402
|
||||
import history_card # noqa: E402
|
||||
from core import rounded_state, state_message, vacuum_extras # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager
|
||||
from test_ha_capabilities import fake_ha
|
||||
|
||||
WORDS = {
|
||||
'component.cover.entity_component._.state.open': 'Open',
|
||||
'component.cover.entity_component._.state.closing': 'Closing',
|
||||
'component.media_player.entity_component._.state.playing': 'Playing',
|
||||
'component.vacuum.entity_component._.state.returning': 'Returning to dock',
|
||||
'component.binary_sensor.entity_component.motion.state.on': 'Detected',
|
||||
'component.sensor.entity_component._.state.on': 'On',
|
||||
'component.roborock.entity.sensor.status.state.charging': 'Charging',
|
||||
'component.roborock.entity.sensor.zeo_state.state.rinsing': 'Rinsing',
|
||||
'component.roborock.entity.select.cleaning_mode.state.vac_and_mop': 'Vacuum and mop',
|
||||
'component.roborock.entity.select.cleaning_mode.state.vacuum': 'Vacuum only',
|
||||
'component.roborock.entity.select.mop_intensity.state.mild': 'Mild',
|
||||
'component.roborock.entity.select.mop_intensity.state.vac_followed_by_mop': 'Vacuum followed by mop',
|
||||
'component.roborock.entity.vacuum.roborock.state_attributes.fan_speed.state.balanced': 'Balanced',
|
||||
'component.roborock.entity.vacuum.roborock.state_attributes.fan_speed.state.off_raise_main_brush': 'Off (raised brush)',
|
||||
'component.climate.entity_component._.state.heat_cool': 'Heat/Cool',
|
||||
}
|
||||
CLEANING = {'platform': 'roborock', 'translation_key': 'cleaning_mode'}
|
||||
|
||||
|
||||
class Rounding(unittest.TestCase):
|
||||
def test_display_precision(self):
|
||||
self.assertEqual(rounded_state('21.456', 1), '21.5')
|
||||
self.assertEqual(rounded_state('21.45', 1), '21.5', 'half up, as the frontend rounds')
|
||||
self.assertEqual(rounded_state('1249.6', 0), '1250', 'no thousands separator: the screen reads the number')
|
||||
self.assertEqual(rounded_state('-0.04', 1), '0.0')
|
||||
self.assertEqual(rounded_state('3', 2), '3.00')
|
||||
for value in ('unavailable', 'on', '', 'nan', 'inf'):
|
||||
self.assertEqual(rounded_state(value, 1), value)
|
||||
self.assertEqual(rounded_state('21.456', None), '21.456', 'without a precision Home Assistant shows the state as it is')
|
||||
self.assertEqual(rounded_state('21.456', True), '21.456')
|
||||
|
||||
def test_only_sensor_states_are_rounded(self):
|
||||
states = {'sensor.t': {'state': '21.456', 'attributes': {'unit_of_measurement': '°C'}},
|
||||
'number.n': {'state': '21.456', 'attributes': {}}}
|
||||
self.assertEqual(state_message(0, {'entity': 'sensor.t', 'name': ''}, states, precision=1)['state'], '21.5')
|
||||
self.assertEqual(state_message(0, {'entity': 'number.n', 'name': ''}, states, precision=1)['state'], '21.456')
|
||||
self.assertEqual(state_message(0, {'entity': 'sensor.t', 'name': ''}, states)['state'], '21.456')
|
||||
|
||||
|
||||
class Words(unittest.TestCase):
|
||||
def test_home_assistants_word_as_its_frontend_picks_it(self):
|
||||
word = lambda entity, state, attributes=None, entry=None: ha_catalogue.state_word(entity, state, attributes, entry, WORDS)
|
||||
self.assertEqual(word('cover.blind', 'open'), 'Open')
|
||||
self.assertEqual(word('binary_sensor.hall', 'on', {'device_class': 'motion'}), 'Detected')
|
||||
self.assertEqual(word('sensor.washer', 'rinsing', {'device_class': 'enum'}, {'platform': 'roborock', 'translation_key': 'zeo_state'}), 'Rinsing')
|
||||
self.assertEqual(word('sensor.robot', 'charging', {}, {'platform': 'roborock', 'translation_key': 'status'}), 'Charging')
|
||||
self.assertIsNone(word('sensor.robot', 'charging', {}, {'platform': 'roborock'}), 'without a translation key there is no entity word')
|
||||
self.assertIsNone(word('cover.blind', 'jammed'))
|
||||
self.assertIsNone(word('binary_sensor.hall', 'on', {}, {'original_device_class': 'motion'}), 'the device class comes from the state, as in the frontend')
|
||||
self.assertEqual(ha_catalogue.attribute_word('vacuum.s8', 'fan_speed', 'balanced', {}, {'platform': 'roborock', 'translation_key': 'roborock'}, WORDS), 'Balanced')
|
||||
self.assertIsNone(ha_catalogue.state_word('cover.blind', 'open', {}, {}, {}))
|
||||
|
||||
def test_tiles_get_a_word_only_where_they_showed_the_raw_state(self):
|
||||
screen = lambda entity, state, attributes=None, entry=None: ha_catalogue.screen_word(entity, state, attributes, entry, WORDS)
|
||||
self.assertEqual(screen('cover.blind', 'open'), 'Open')
|
||||
self.assertEqual(screen('media_player.sonos', 'playing'), 'Playing')
|
||||
self.assertEqual(screen('vacuum.s8', 'returning'), 'Returning to dock')
|
||||
self.assertEqual(screen('select.s8_mode', 'vac_and_mop', {}, {'platform': 'roborock', 'translation_key': 'cleaning_mode'}), 'Vacuum and mop')
|
||||
self.assertIsNone(screen('binary_sensor.hall', 'on', {'device_class': 'motion'}), 'binary sensors keep the words of firmware 0.2.53')
|
||||
self.assertIsNone(screen('sensor.power', '12.5'), 'a number keeps its number')
|
||||
self.assertIsNone(screen('light.lamp', 'on'))
|
||||
|
||||
|
||||
class WordsBeyondTiles(unittest.TestCase):
|
||||
def test_the_top_bar_takes_home_assistants_word_where_it_showed_the_state_as_it_came(self):
|
||||
washer = {'state': 'rinsing', 'attributes': {'device_class': 'enum'}}
|
||||
self.assertEqual(header_bar.value('sensor.washer', washer, {'platform': 'roborock', 'translation_key': 'zeo_state'}, words=WORDS), ('Rinsing', None))
|
||||
mode = {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop']}}
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING, words=WORDS), ('Vacuum and mop', None))
|
||||
self.assertEqual(header_bar.value('select.s8_mode', mode, CLEANING), ('vac_and_mop', None), 'without words as before')
|
||||
# The bar's own short words stay until Max chooses: Returning, Auto, Motion.
|
||||
self.assertEqual(header_bar.value('vacuum.s8', {'state': 'returning', 'attributes': {}}, None, words=WORDS), ('Returning', None))
|
||||
self.assertEqual(header_bar.value('climate.hall', {'state': 'heat_cool', 'attributes': {}}, None, words=WORDS), ('Auto', None))
|
||||
motion = {'state': 'on', 'attributes': {'device_class': 'motion'}}
|
||||
self.assertEqual(header_bar.value('binary_sensor.hall', motion, None, words=WORDS), ('Motion', None))
|
||||
layout = {'header': {'items': [{'type': 'entity', 'entity': 'select.s8_mode', 'content': 'state', 'icon': 'none', 'show': 'always'}]}}
|
||||
bar = header_bar.message(layout, {'select.s8_mode': mode}, {'select.s8_mode': CLEANING}, words=WORDS)
|
||||
self.assertEqual(bar['items'], [{'k': 'text', 't': 'Vacuum and mop'}])
|
||||
self.assertEqual(header_bar.preview(layout['header'], {'select.s8_mode': mode}, {'select.s8_mode': CLEANING}, words=WORDS)[0]['t'], 'Vacuum and mop')
|
||||
|
||||
def test_a_history_timeline_speaks_the_same_words(self):
|
||||
changes = [(0, 'vacuum'), (3600, 'vac_and_mop'), (7200, 'custom')]
|
||||
message = history_card.timeline('select.s8_mode', 24, changes, 0, 24 * 3600, timezone.utc, {}, entry=CLEANING, translations=WORDS)
|
||||
self.assertEqual(sorted(label for label, _, _ in message['states']), ['Custom', 'Vacuum and mop', 'Vacuum only'])
|
||||
self.assertEqual(dict(message['words']), {'vacuum': 'Vacuum only', 'vac_and_mop': 'Vacuum and mop', 'custom': 'Custom'})
|
||||
before = history_card.timeline('select.s8_mode', 24, changes, 0, 24 * 3600, timezone.utc, {})
|
||||
self.assertEqual(dict(before['words']), {'vacuum': 'Vacuum', 'vac_and_mop': 'Vac and mop', 'custom': 'Custom'}, 'without words as before')
|
||||
door = history_card.timeline('binary_sensor.front', 24, [(0, 'off'), (60, 'on')], 0, 3600, timezone.utc, {'device_class': 'door'},
|
||||
entry=None, translations={'component.binary_sensor.entity_component.door.state.on': 'Open'})
|
||||
self.assertEqual(dict(door['words'])['on'], 'Open')
|
||||
|
||||
def test_vacuum_chips_take_home_assistants_word_where_the_screen_has_no_label(self):
|
||||
device = [{'entity_id': 'vacuum.s8', 'platform': 'roborock', 'translation_key': 'roborock'},
|
||||
{'entity_id': 'select.s8_mop_intensity', 'platform': 'roborock', 'translation_key': 'mop_intensity'}]
|
||||
states = {'vacuum.s8': {'state': 'docked', 'attributes': {'fan_speed_list': ['quiet', 'balanced', 'off_raise_main_brush']}},
|
||||
'select.s8_mop_intensity': {'state': 'mild', 'attributes': {'options': ['mild', 'vac_followed_by_mop']}}}
|
||||
extra = vacuum_extras({'entity': 'vacuum.s8'}, states, device)
|
||||
self.assertEqual((extra['fan']['l'], extra['water']['l']), (['Quiet', 'Normal', 'Off raise main brush'], ['Mild', 'Vac followed by mop']))
|
||||
ha_catalogue.chip_words(extra, 'vacuum.s8', states, device, WORDS)
|
||||
self.assertEqual(extra['fan']['l'], ['Quiet', 'Normal', 'Off (raised brush)'], "the screen's own Normal stays")
|
||||
self.assertEqual(extra['water']['l'], ['Mild', 'Vacuum followed by mop'])
|
||||
self.assertEqual(extra['fan']['o'], ['quiet', 'balanced', 'off_raise_main_brush'], 'the values the screen sends stay')
|
||||
self.assertIsNone(ha_catalogue.chip_words(None, 'vacuum.s8', states, device, WORDS))
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class TileMessages(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_tile_message_carries_the_word_and_the_rounded_value(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.state_words = WORDS
|
||||
ha.states['sensor.living_temperature'] = {'state': '21.456', 'attributes': {'unit_of_measurement': '°C', 'state_class': 'measurement'}}
|
||||
ha.registry.append({'entity_id': 'sensor.living_temperature', 'platform': 'demo', 'options': {'sensor': {'display_precision': 1}}})
|
||||
ha.states['sensor.washer'] = {'state': 'rinsing', 'attributes': {'device_class': 'enum'}}
|
||||
ha.registry.append({'entity_id': 'sensor.washer', 'platform': 'roborock', 'translation_key': 'zeo_state'})
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
temperature = await m.tile_message(0, {'entity': 'sensor.living_temperature', 'name': ''})
|
||||
self.assertEqual((temperature['state'], temperature.get('x', {}).get('w')), ('21.5', None))
|
||||
washer = await m.tile_message(1, {'entity': 'sensor.washer', 'name': ''})
|
||||
self.assertEqual((washer['state'], washer['x']['w']), ('rinsing', 'Rinsing'))
|
||||
curtains = await m.tile_message(2, {'entity': 'cover.curtains', 'name': ''})
|
||||
self.assertEqual(curtains['x']['w'], 'Open')
|
||||
ha.state_words = {}
|
||||
self.assertNotIn('x', await m.tile_message(2, {'entity': 'cover.curtains', 'name': ''}), 'no words: the raw state, as before')
|
||||
|
||||
async def test_the_top_bar_timeline_and_vacuum_card_get_the_words_from_the_app(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.state_words = WORDS
|
||||
ha.states['select.s8_mode'] = {'state': 'vac_and_mop', 'attributes': {'options': ['vacuum', 'vac_and_mop']}}
|
||||
ha.states['vacuum.s8'] = {'state': 'docked', 'attributes': {'fan_speed_list': ['balanced', 'off_raise_main_brush']}}
|
||||
# The mode select sits on another device here: with one on the robot's device, suction off belongs to Mop.
|
||||
ha.registry += [{'entity_id': 'select.s8_mode', 'device_id': 'hall', **CLEANING},
|
||||
{'entity_id': 'vacuum.s8', 'device_id': 's8', 'platform': 'roborock', 'translation_key': 'roborock'}]
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
layout = {'tiles': [], 'header': {'items': [{'type': 'entity', 'entity': 'select.s8_mode', 'content': 'state', 'icon': 'none', 'show': 'always'}]}}
|
||||
self.assertEqual(m.header_message(layout)['items'], [{'k': 'text', 't': 'Vacuum and mop'}])
|
||||
vacuum = await m.tile_message(0, {'entity': 'vacuum.s8', 'name': ''})
|
||||
self.assertEqual(vacuum['x']['fan']['l'], ['Normal', 'Off (raised brush)'], "the screen's own Normal stays")
|
||||
async def state_changes(entity, hours):
|
||||
return [(0, 'vacuum'), (60, 'vac_and_mop')]
|
||||
ha.state_changes = state_changes
|
||||
timeline = await m.build_card_history('select.s8_mode', 24, 'timeline')
|
||||
self.assertEqual(dict(timeline['words'])['vac_and_mop'], 'Vacuum and mop')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Perform action (app 0.2.67, firmware 0.2.58): a tap runs an action Home Assistant offers for the tile's entity, with
|
||||
data for its fields. The editor lists the actions under Home Assistant's own names, a save accepts only what Home
|
||||
Assistant would take, and the screen gets a compact form it sends as ESPHome action data and templates."""
|
||||
import asyncio
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import ha_catalogue # noqa: E402
|
||||
from core import (action_for_screen, apply_tile_event, layout_snapshot, screen_options, state_message, # noqa: E402
|
||||
validate_layout, validate_tap_action)
|
||||
from test_ha_capabilities import ENTITIES, SERVICES, actions, state # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager, Refused, create_app
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from test_ha_capabilities import fake_ha
|
||||
|
||||
# Real descriptions of Home Assistant 2026.9.2 with their fields, and its English names for them.
|
||||
DESCRIBED = copy.deepcopy(SERVICES)
|
||||
DESCRIBED['cover']['set_cover_position']['fields'] = {
|
||||
'position': {'required': True, 'selector': {'number': {'min': 0.0, 'max': 100.0, 'unit_of_measurement': '%', 'step': 1.0, 'mode': 'slider'}}},
|
||||
'speed': {'example': 'fast', 'filter': {'supported_features': [256]}, 'selector': {'state': {'attribute': 'speed'}}}}
|
||||
NAMES = {
|
||||
'component.cover.services.set_cover_position.name': 'Set cover position',
|
||||
'component.cover.services.set_cover_position.description': 'Moves a cover to a specific position.',
|
||||
'component.cover.services.set_cover_position.fields.position.name': 'Position',
|
||||
'component.cover.services.set_cover_position.fields.position.description': 'Target position.',
|
||||
'component.cover.services.toggle.name': 'Toggle cover',
|
||||
'component.homeassistant.services.toggle.name': 'Generic toggle',
|
||||
'component.sonos.services.snapshot.name': 'Snapshot',
|
||||
}
|
||||
|
||||
|
||||
def template_value(template):
|
||||
"""What Home Assistant renders from a template the app wrote: `{{ "<json>" | from_json }}`."""
|
||||
inner = re.fullmatch(r'\{\{ (".*") \| from_json \}\}', template)
|
||||
return json.loads(json.loads(inner.group(1)))
|
||||
|
||||
|
||||
class StoredAction(unittest.TestCase):
|
||||
def test_what_a_tap_action_may_hold(self):
|
||||
self.assertEqual(validate_tap_action({'action': 'cover.toggle'}), {'action': 'cover.toggle'})
|
||||
self.assertEqual(validate_tap_action({'action': 'sonos.snapshot', 'data': {'with_group': True}}),
|
||||
{'action': 'sonos.snapshot', 'data': {'with_group': True}})
|
||||
for bad in ({'action': 'cover'}, {'action': 'Cover.toggle'}, {'action': 'cover.toggle', 'target': {}}, 'cover.toggle',
|
||||
{'action': 'cover.toggle', 'data': {'entity_id': 'cover.other'}}, {'action': 'cover.toggle', 'data': {'Bad key': 1}},
|
||||
{'action': 'light.turn_on', 'data': {f'f{i}': i for i in range(9)}},
|
||||
{'action': 'light.turn_on', 'data': {'effect': 'x' * 500}},
|
||||
{'action': 'light.turn_on', 'data': {'brightness': float('nan')}}):
|
||||
with self.assertRaises(ValueError, msg=bad):
|
||||
validate_tap_action(bad)
|
||||
|
||||
def test_the_layout_keeps_the_action_only_with_perform_action(self):
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': [
|
||||
{'entity': 'cover.curtains', 'options': {'tap': 'action', 'action': {'action': 'cover.set_cover_position', 'data': {'position': 50}}}},
|
||||
{'entity': 'light.hood', 'options': {'tap': 'toggle', 'action': {'action': 'light.turn_on'}}}]})
|
||||
self.assertEqual(layout['tiles'][0]['options']['action'], {'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
self.assertNotIn('action', layout['tiles'][1]['options'], 'another tap choice drops a stale action')
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'cover.curtains', 'options': {'tap': 'action'}}]})
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'screen.clock', 'options': {'tap': 'action', 'action': {'action': 'light.turn_on'}}}]})
|
||||
|
||||
def test_the_screen_gets_text_as_data_and_every_other_value_as_a_template(self):
|
||||
value = {'action': 'light.turn_on', 'data': {'effect': 'colorloop', 'brightness_pct': 40, 'rgb_color': [255, 0, 0],
|
||||
'flash': True, 'transition': 1.5, 'name': 'Café "late"'}}
|
||||
act = action_for_screen(validate_tap_action(value))
|
||||
self.assertEqual(act['s'], 'light.turn_on')
|
||||
self.assertEqual(act['d'], [['effect', 'colorloop'], ['name', 'Café "late"']])
|
||||
self.assertEqual({key: template_value(text) for key, text in act['t']},
|
||||
{'brightness_pct': 40, 'rgb_color': [255, 0, 0], 'flash': True, 'transition': 1.5})
|
||||
tile = {'entity': 'cover.curtains', 'name': '', 'options': {'tap': 'action', 'action': {'action': 'cover.toggle'}}}
|
||||
self.assertEqual(screen_options(tile, {}), {'tap': 'action', 'act': {'s': 'cover.toggle'}})
|
||||
message = state_message(0, tile, {'cover.curtains': state('cover.curtains')})
|
||||
self.assertEqual(message['o'], {'tap': 'action', 'act': {'s': 'cover.toggle'}})
|
||||
broken = {'entity': 'cover.curtains', 'name': '', 'options': {'tap': 'action', 'action': {'action': 'nonsense'}}}
|
||||
self.assertEqual(screen_options(broken, {}), {'tap': 'action'}, 'a stored action the screen could not send stays behind')
|
||||
|
||||
def test_tile_events_and_the_layout_sensor(self):
|
||||
layout = apply_tile_event({'title': 'Hall', 'tiles': []}, 'add',
|
||||
{'entity': 'cover.curtains', 'action': 'cover.set_cover_position', 'data': {'position': 30}})
|
||||
options = layout['tiles'][0]['options']
|
||||
self.assertEqual((options['tap'], options['action']), ('action', {'action': 'cover.set_cover_position', 'data': {'position': 30}}))
|
||||
kept = apply_tile_event(layout, 'add', {'entity': 'cover.curtains', 'tap': 'detail'})
|
||||
self.assertEqual(validate_layout(kept)['tiles'][0]['options'].get('tap'), 'detail')
|
||||
self.assertNotIn('action', validate_layout(kept)['tiles'][0]['options'])
|
||||
snapshot = layout_snapshot({'name': 'Hall'}, validate_layout(layout))
|
||||
self.assertEqual((snapshot['tiles'][0]['tap'], snapshot['tiles'][0]['action']['action']), ('action', 'cover.set_cover_position'))
|
||||
|
||||
|
||||
class HomeAssistantsList(unittest.TestCase):
|
||||
def test_actions_under_home_assistants_names_with_the_fields_of_this_entity(self):
|
||||
found = ha_catalogue.action_choices('media_player.sonos', actions('media_player.sonos'), state('media_player.sonos'), DESCRIBED, NAMES, 'sonos')
|
||||
order = [item['action'] for item in found]
|
||||
self.assertLess(order.index('media_player.volume_set'), order.index('sonos.snapshot'))
|
||||
self.assertLess(order.index('sonos.snapshot'), order.index('homeassistant.toggle'))
|
||||
self.assertEqual(next(item for item in found if item['action'] == 'sonos.snapshot')['name'], 'Snapshot')
|
||||
curtains = ha_catalogue.action_choices('cover.curtains', actions('cover.curtains'), state('cover.curtains'), DESCRIBED, NAMES, 'zha')
|
||||
position = next(item for item in curtains if item['action'] == 'cover.set_cover_position')
|
||||
self.assertEqual((position['name'], position['description']), ('Set cover position', 'Moves a cover to a specific position.'))
|
||||
self.assertEqual([(f['key'], f['name'], f['required']) for f in position['fields']], [('position', 'Position', True)],
|
||||
'speed needs the SPEED feature, which these curtains lack')
|
||||
self.assertEqual(next(item for item in curtains if item['action'] == 'cover.close_cover')['name'], 'cover.close_cover',
|
||||
'without a name anywhere the action id stands in')
|
||||
weather = ha_catalogue.action_choices('weather.buienradar', actions('weather.buienradar'), state('weather.buienradar'), DESCRIBED, NAMES)
|
||||
self.assertNotIn('weather.get_forecasts', [item['action'] for item in weather], 'an action that only answers with data is left out')
|
||||
|
||||
def test_a_field_that_asks_for_a_value_of_the_entity_lists_its_values(self):
|
||||
described = copy.deepcopy(DESCRIBED)
|
||||
described['media_player']['select_source'] = {'target': {'entity': [{'domain': ['media_player'], 'supported_features': [2048]}]},
|
||||
'fields': {'source': {'required': True, 'selector': {'state': {'attribute': 'source'}}}}}
|
||||
sonos = {**state('media_player.sonos'), 'attributes': {**state('media_player.sonos')['attributes'], 'source_list': ['TV', 'NPO Radio 2', 'Qmusic']}}
|
||||
found = ha_catalogue.action_choices('media_player.sonos', ['media_player.select_source'], sonos, described, NAMES)
|
||||
self.assertEqual(found[0]['fields'][0]['options'], ['TV', 'NPO Radio 2', 'Qmusic'])
|
||||
self.assertEqual(ha_catalogue.attribute_options({'fan_modes': ['auto', 'low']}, 'fan_mode'), ['auto', 'low'])
|
||||
self.assertEqual(ha_catalogue.attribute_options({'supported_speeds': ['slow', 'fast']}, 'speed'), ['slow', 'fast'])
|
||||
self.assertIsNone(ha_catalogue.attribute_options({'source_list': []}, 'source'))
|
||||
|
||||
def test_what_a_save_refuses(self):
|
||||
curtains = actions('cover.curtains')
|
||||
problem = lambda action, entity='cover.curtains', offered=curtains: ha_catalogue.action_problem(
|
||||
entity, 'Curtains', action, state(entity), offered, DESCRIBED)
|
||||
self.assertIsNone(problem({'action': 'cover.set_cover_position', 'data': {'position': 50}}))
|
||||
self.assertIsNone(problem({'action': 'cover.toggle'}))
|
||||
self.assertIn("doesn't offer media_player.volume_set", problem({'action': 'media_player.volume_set'}))
|
||||
self.assertIn('needs a value for position', problem({'action': 'cover.set_cover_position'}))
|
||||
self.assertIn('no field speed', problem({'action': 'cover.set_cover_position', 'data': {'position': 5, 'speed': 'fast'}}))
|
||||
self.assertIn('only answers with data', problem({'action': 'weather.get_forecasts'}, 'weather.buienradar', actions('weather.buienradar')))
|
||||
self.assertIsNone(problem({'action': 'cover.toggle'}, offered=None), 'nothing is refused while Home Assistant cannot say')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class SavingAndTheEditor(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_names_come_with_the_action_list_and_a_refused_request_leaves_none(self):
|
||||
ha = fake_ha()
|
||||
answers = {'get_services': DESCRIBED, 'frontend/get_translations': {'resources': NAMES}, 'frontend/get_icons': {'resources': {}}}
|
||||
async def request(kind, **data):
|
||||
if kind in answers:
|
||||
return answers[kind]
|
||||
raise AssertionError(kind)
|
||||
ha.request = request
|
||||
await ha.fetch_services()
|
||||
self.assertEqual(ha.service_names['component.cover.services.toggle.name'], 'Toggle cover')
|
||||
async def refusing(kind, **data):
|
||||
if kind == 'frontend/get_translations':
|
||||
raise Refused('Unknown command.')
|
||||
return DESCRIBED
|
||||
ha.request, ha.service_names = refusing, {}
|
||||
await ha.fetch_services()
|
||||
self.assertEqual(ha.service_names, {})
|
||||
self.assertEqual(ha.services, DESCRIBED)
|
||||
|
||||
async def test_a_save_takes_only_what_home_assistant_would(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.services = DESCRIBED
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
def layout(action):
|
||||
return {'title': 'Living room', 'tiles': [{'entity': 'cover.curtains', 'name': '', 'slot': 0,
|
||||
'options': {'tap': 'action', 'action': action}}]}
|
||||
with self.assertRaisesRegex(ValueError, 'needs a value for position'):
|
||||
await m.check_supported('text.d1_tiles', layout({'action': 'cover.set_cover_position'}))
|
||||
with self.assertRaisesRegex(ValueError, "doesn't offer light.turn_on"):
|
||||
await m.check_supported('text.d1_tiles', layout({'action': 'light.turn_on'}))
|
||||
good = layout({'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
await m.check_supported('text.d1_tiles', good)
|
||||
m.save('text.d1_tiles', good)
|
||||
# Home Assistant drops the action later (an integration removed): the saved tile still saves.
|
||||
ha.services = {key: value for key, value in DESCRIBED.items() if key != 'cover'}
|
||||
ha.services_rev += 1
|
||||
await m.check_supported('text.d1_tiles', {**good, 'title': 'Renamed'})
|
||||
saved = json.loads((Path(tmp) / 'screens.json').read_text())['screens']['text.d1_tiles']
|
||||
self.assertEqual(saved['tiles'][0]['options']['action'], {'action': 'cover.set_cover_position', 'data': {'position': 50}})
|
||||
|
||||
async def test_claude_in_home_assistant_sets_an_action(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
ha = fake_ha()
|
||||
ha.services = DESCRIBED
|
||||
m = Manager(ha, path)
|
||||
ha.tile_events = asyncio.Queue()
|
||||
ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.curtains', 'action': 'cover.set_cover_position'}))
|
||||
ha.tile_events.put_nowait(('esp_screens_add_tile', {'entity': 'cover.curtains', 'action': 'cover.set_cover_position', 'data': {'position': 70}}))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(200):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(ha.fired) == 2:
|
||||
break
|
||||
worker.cancel()
|
||||
answers = [answer for _, answer in ha.fired]
|
||||
self.assertFalse(answers[0]['ok'])
|
||||
self.assertIn('needs a value for position', answers[0]['error'])
|
||||
self.assertTrue(answers[1]['ok'], answers[1])
|
||||
tile = json.loads(path.read_text())['screens']['text.d1_tiles']['tiles'][0]
|
||||
self.assertEqual(screen_options(tile, {})['act'], {'s': 'cover.set_cover_position', 't': [['position', '{{ "70" | from_json }}']]})
|
||||
|
||||
async def test_the_editor_asks_for_an_entitys_actions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
ha.services, ha.service_names = DESCRIBED, NAMES
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
found = (await (await client.get('/api/entity-actions?entity=cover.curtains')).json())['actions']
|
||||
unknown = (await (await client.get('/api/entity-actions?entity=light.unknown')).json())['actions']
|
||||
nonsense = (await (await client.get('/api/entity-actions?entity=../etc')).json())['actions']
|
||||
self.assertEqual(found[0]['action'].split('.')[0], 'cover')
|
||||
self.assertIn({'action': 'cover.toggle', 'name': 'Toggle cover', 'description': '', 'fields': []}, found)
|
||||
self.assertIsNone(unknown)
|
||||
self.assertIsNone(nonsense)
|
||||
|
||||
|
||||
class Editor(unittest.TestCase):
|
||||
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
|
||||
def test_perform_action_is_a_tap_choice_with_home_assistants_list(self):
|
||||
self.assertIn('taps.push(["action", "Perform action"]);', self.SCRIPT)
|
||||
self.assertIn('api(`entity-actions?entity=${encodeURIComponent(entity)}`)', self.SCRIPT)
|
||||
self.assertIn('if (tap === "action") body.append(actionPicker(tile));', self.SCRIPT)
|
||||
# Fields follow Home Assistant's selectors; an empty field is left out of the data.
|
||||
for marker in ('kind === "boolean"', 'kind === "number" || kind === "color_temp"', 'config.options', 'delete data[field.key]'):
|
||||
self.assertIn(marker, self.SCRIPT)
|
||||
self.assertIn('supportsFirmware(0, 2, 58)', self.SCRIPT)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -179,5 +179,67 @@ int main() {
|
||||
assert(!strcmp(binary_state_text("battery_charging", false), "Not charging") && !strcmp(binary_state_text("battery", true), "Low"));
|
||||
assert(!strcmp(binary_state_text("", true), "On") && !strcmp(binary_state_text("", false), "Off"));
|
||||
assert(!strcmp(binary_state_text("future_class", true), "On") && !strcmp(binary_state_text("Door", true), "On"));
|
||||
|
||||
// Tap routing (firmware 0.2.58). `legacy` is runtime_tiles::event() of firmware 0.2.56, line by line: every tap
|
||||
// choice an existing layout can hold must route exactly as before, so no screen changes behaviour on the update.
|
||||
struct Legacy { TapRoute route; std::string service; bool busy; };
|
||||
auto legacy = [](const Tile &tile, bool hold) -> Legacy {
|
||||
const std::string d = tile.domain();
|
||||
if (tile.tap == "none") return {TapRoute::NONE, "", false};
|
||||
bool open = hold || d == "climate" || d == "vacuum" || d == "cover";
|
||||
if (!hold && tile.tap == "detail") open = true;
|
||||
if (!hold && tile.tap == "toggle") open = false;
|
||||
if (d == "media_player" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "media_player.toggle", false};
|
||||
if (d == "climate" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "climate.toggle", false};
|
||||
if (d == "timer" && !open) return {TapRoute::ACTION, tile.state == "active" ? "timer.pause" : "timer.start", false};
|
||||
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")
|
||||
return {TapRoute::CARD, "", true};
|
||||
if (open) return d == "light" || d == "climate" || d == "vacuum" || d == "fan" ? Legacy{TapRoute::OVERLAY, "", true} : Legacy{TapRoute::CARD, "", false};
|
||||
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") return {TapRoute::ACTION, d + ".toggle", false};
|
||||
if (d == "scene" || d == "script") return {TapRoute::ACTION, d + ".turn_on", false};
|
||||
if (d == "button" || d == "input_button") return {TapRoute::ACTION, d + ".press", false};
|
||||
return {TapRoute::NONE, "", false};
|
||||
};
|
||||
const char *domains[] = {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor",
|
||||
"binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player",
|
||||
"button", "input_button", "sun", "timer", "person"};
|
||||
// What validate_layout accepted before 0.2.67: `toggle` only on these six domains.
|
||||
auto toggled_before = [](const std::string &d) {
|
||||
return d == "light" || d == "switch" || d == "input_boolean" || d == "fan" || d == "media_player" || d == "climate";
|
||||
};
|
||||
int compared = 0;
|
||||
// "action" without an action (an app before 0.2.67) taps like firmware 0.2.56 did with a value it didn't know: automatically.
|
||||
for (const char *domain : domains) for (const char *choice : {"auto", "detail", "toggle", "none", "action"})
|
||||
for (const char *state : {"on", "off", "active", "idle", "open"}) for (bool hold : {false, true}) {
|
||||
Tile tile = make((std::string(domain) + ".x").c_str(), state);
|
||||
tile.tap = choice;
|
||||
Tap now = tap_route(tile, hold);
|
||||
if (std::string(choice) == "toggle" && !toggled_before(domain)) {
|
||||
// New in 0.2.58: a short tap toggles any entity the app allowed it for; holding still opens the card as before.
|
||||
if (!hold) { assert(now.route == TapRoute::ACTION && now.service == std::string(domain) + ".toggle"); continue; }
|
||||
}
|
||||
Legacy before = legacy(tile, hold);
|
||||
assert(now.route == before.route && now.service == before.service);
|
||||
if (now.route != TapRoute::ACTION) assert(now.busy == before.busy);
|
||||
++compared;
|
||||
}
|
||||
assert(compared == 1020); // 22 domains x 5 choices x 5 states x 2 gestures, less the 80 new short toggles
|
||||
// The example of issue #7: a short tap on a cover set to On / off sends cover.toggle; holding opens its card.
|
||||
Tile issue7 = make("cover.curtain", "open", feature::COVER_OPEN | feature::COVER_CLOSE | feature::COVER_STOP);
|
||||
issue7.tap = "toggle";
|
||||
assert(tap_route(issue7, false).route == TapRoute::ACTION && tap_route(issue7, false).service == "cover.toggle");
|
||||
assert(tap_route(issue7, true).route == TapRoute::CARD);
|
||||
issue7.tap = "auto";
|
||||
assert(tap_route(issue7, false).route == TapRoute::CARD && tap_route(issue7, false).busy);
|
||||
// An action of the tile's own choosing (firmware 0.2.58): a short tap performs it, holding still opens the card.
|
||||
issue7.tap = "action";
|
||||
issue7.edit_extra().action = "cover.set_cover_position";
|
||||
issue7.edit_extra().action_data = {{"position", "50"}};
|
||||
assert(tap_route(issue7, false).route == TapRoute::CUSTOM && tap_route(issue7, false).service == "cover.set_cover_position");
|
||||
assert(tap_route(issue7, true).route == TapRoute::CARD);
|
||||
assert(runtime_tiles::valid_action("cover.toggle") && runtime_tiles::valid_action("sonos.snapshot") && runtime_tiles::valid_action("homeassistant.turn_on"));
|
||||
assert(!runtime_tiles::valid_action("cover") && !runtime_tiles::valid_action("Cover.toggle") && !runtime_tiles::valid_action("a.b.c") &&
|
||||
!runtime_tiles::valid_action(".toggle") && !runtime_tiles::valid_action("cover.") && !runtime_tiles::valid_action("cover.to ggle"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user