#pragma once #include "runtime_model.h" #include "header_bar.h" #include "tile_palette.h" #include "theme.h" #include "tile_icon.h" #include "screen_settings.h" #include "settings_screen.h" #include "esphome/core/preferences.h" #include "cyd_ui.h" #include "light_controls.h" #include "effects_page.h" #include "tile_controls.h" #include "history_view.h" #include "camera_view.h" #include "media_card.h" #include "swipe_profile.h" #include "esphome/components/json/json_util.h" #include "esphome/components/api/api_server.h" #include "esphome/core/hal.h" #include "esphome/core/util.h" #include "esphome/core/time.h" #include "lvgl.h" #include #include namespace runtime_tiles { // What the screen says, in the language its firmware was built for (screen_text.h, app 0.2.90). using screen_text::fill; using screen_text::plural; using screen_text::tr; namespace txt = screen_text::txt; inline bool enabled = false; // Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the // screen can change itself; these names stay as the way the rest of the firmware reaches them. using settings_screen::swipe_pages; using settings_screen::rotation; using settings_screen::rotation_supported; using settings_screen::auto_home; using settings_screen::auto_home_seconds; using settings_screen::page_buttons; inline esphome::ESPPreferenceObject rotation_preference; inline esphome::ESPPreferenceObject buttons_preference; inline esphome::ESPPreferenceObject swipe_preference; inline esphome::ESPPreferenceObject home_preference; inline esphome::ESPPreferenceObject dark_preference; // The number format of Settings -> Language & region (app 0.2.90), kept for the next start: screen_text::number_style. inline esphome::ESPPreferenceObject numbers_preference; inline Model model; inline std::string inbox; inline const lv_font_t *watch_font = nullptr; inline const lv_font_t *mini_icon_font = nullptr; // The large icon font of a full-page card (firmware 0.2.62+): the domain icons only, so the flash stays free. inline const lv_font_t *big_icon_font = nullptr; // Page shown by the navigation bar and the swipe (the profile's tile_page), remembered by show_page. inline int *shown_page = nullptr; inline void go_to_page(int page); inline const lv_font_t *watch_value_font = nullptr, *watch_icon_font = nullptr; // Big digits for the clock card; the board profile sets it with the local time source. inline const lv_font_t *clock_font = nullptr; // Text in the -/+ pill and the run key of direct controls; the board profile sets it. inline const lv_font_t *control_font = nullptr; // The smallest regular text (sublabel): axis labels and the legend of the history card. inline const lv_font_t *small_font = nullptr; inline lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously // Top bar (0.2.32+): the profile's clock label only lends its place and margin; values use the // profile's text font, icons its small icon font. Set by the board profile at boot. inline header_bar::Bar header; inline lv_obj_t *time_label = nullptr; inline const lv_font_t *header_text_font = nullptr, *header_icon_font = nullptr; inline void render_header(); inline std::function now_time; // True while the screen is in use: the profile reports standby (also the night level) as not awake. // The analog clock's second hand runs only then; a standby screen stays on its minute redraws. inline std::function screen_awake; inline bool awake() { return !screen_awake || screen_awake(); } inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast(t.timestamp) : 0; } inline void tick(); inline void refresh_tile(size_t index); // Style setters that only touch a property when it changes (defined with the card renderers below). inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0); inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0); inline void set_font(lv_obj_t *obj, const lv_font_t *value); #ifdef SWIPE_PROFILE inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning); #endif inline void refresh_header_only(); inline void refresh_all(); inline void refresh_detail(unsigned index); inline const char *weather_icon(const std::string &condition); inline const char *weather_text(const std::string &condition); inline std::string timer_text(const Tile &t); inline std::string last_run_text(uint32_t epoch); inline void history_received(); // Camera images full screen and on an alert (firmware 0.2.57+, the Guition binds them; see the end of this file). inline void camera_open(const std::string &entity, const std::string &name); inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url); inline bool camera_supported(); // The media card's album cover (firmware 0.2.64+): the card or a tile over the whole page says which cover it shows, // the board's online_image loads it; see the end of this file. enum class CoverOwner : uint8_t { NONE, DETAIL, TILE }; inline void cover_want(const std::string &entity, const std::string &picture, int size, uint32_t background, CoverOwner owner, size_t slot); inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t background); // The card's cover on screen, and the part a media tile keeps its cover in; both go with the image buffer. inline lv_obj_t *media_detail_picture = nullptr; constexpr unsigned MEDIA_PICTURE = 14; inline void media_action(Tile &t, int cmd); inline const char *icon_for(const Tile &tile); inline void label(lv_obj_t *obj, const std::string &text); inline int active_index = -1; inline uint32_t last_received = 0; // Seconds between the manager's full repeats; every layout message declares it (app // 0.2.26+). Older managers get the 120 s that app 0.2.20 introduced. inline uint32_t keepalive_seconds = 120; // Revision of the layout the manager sent last (app 0.2.39+). A keepalive ping carries the // manager's revision; a mismatch (a restart, a demo layout) asks for the whole layout again. inline std::string layout_rev; // History on a detail card (firmware 0.2.51+): the last history the manager sent (app 0.2.59+ answers // history_request with op "history"), for the card that asked. struct HistoryState { std::string label; uint32_t color = 0; uint32_t seconds = 0; }; struct History { std::string entity, unit; uint32_t hours = 0, start = 0, end = 0, began = 0; int32_t offset = 0; bool line = true, has_high = false, has_low = false; float values[history_view::PARTS]{}; bool has[history_view::PARTS]{}; float bottom = 0, top = 1, high = 0, low = 0; uint32_t high_at = 0, low_at = 0; int decimals = 1, active = -1; std::vector> ticks; std::vector times; uint16_t slots = 0; std::vector runs; std::vector states; // Home Assistant's words for the states (raw state, words), so the heading follows a state that changes. std::vector> words; }; inline History history; // The range the open card shows (1, 24 or 168 hours); what it asked for last and when; whether that answer came. inline uint32_t history_hours = 24, history_asked_at = 0, history_asked_hours = 0, history_received_at = 0; inline std::string history_asked_entity; inline bool history_answered = false; inline std::function layout_changed, refresh, dismiss, settings_changed; inline esphome::ESPPreferenceObject settings_preference; // The two extra values of 0.2.44+ in one record: going back to page 1 by itself, and after how long. struct HomeTimeout { uint32_t enabled = 1, seconds = 120; }; inline void load_settings() { settings_preference = esphome::global_preferences->make_preference(0x53435231); swipe_preference = esphome::global_preferences->make_preference(0x53575031); home_preference = esphome::global_preferences->make_preference(0x484F4D31); dark_preference = esphome::global_preferences->make_preference(0x44524B31); buttons_preference = esphome::global_preferences->make_preference(0x50474231); numbers_preference = esphome::global_preferences->make_preference(0x4E554D31); uint32_t numbers_saved=0; if(numbers_preference.load(&numbers_saved) && numbers_saved<=3)screen_text::number_style=(uint8_t)numbers_saved; uint32_t swipe_saved=0; if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1; uint32_t dark_saved=0; if(dark_preference.load(&dark_saved))settings_screen::dark_mode=dark_saved==1; uint32_t buttons_saved=1; if(buttons_preference.load(&buttons_saved))page_buttons=buttons_saved!=0; HomeTimeout home; if(home_preference.load(&home) && home.seconds>=30 && home.seconds<=3600){ auto_home=home.enabled?1:0;auto_home_seconds=(int32_t)home.seconds; } if(rotation_supported){ rotation_preference=esphome::global_preferences->make_preference(0x524F5431); uint32_t saved=0; if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=(int32_t)saved; } screen_settings::Settings saved; if (settings_preference.load(&saved) && saved.valid()) screen_settings::current = saved; } // Everything the screen remembers, written in one go. ESPHome batches the flash writes, so a row of // taps on -/+ costs one write, and a value that did not change costs nothing. inline void persist_settings() { if (screen_settings::current.valid()) settings_preference.save(&screen_settings::current); uint32_t swipe = swipe_pages ? 1 : 0; swipe_preference.save(&swipe); HomeTimeout home{(uint32_t) (auto_home ? 1 : 0), (uint32_t) std::clamp(auto_home_seconds, 30, 3600)}; home_preference.save(&home); uint32_t dark = settings_screen::dark_mode ? 1 : 0; dark_preference.save(&dark); uint32_t buttons = page_buttons ? 1 : 0; buttons_preference.save(&buttons); if (rotation_supported) { uint32_t turned = (uint32_t) rotation; rotation_preference.save(&turned); } } inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) { // Require the complete known schema; validate before touching any runtime state. if (obj.size() != 11) return false; const char *flags[] = {"standby_enabled", "night_enabled", "show_clock", "clock_24h", "home_on_standby"}; int32_t *flag_values[] = {&s.standby_enabled, &s.night_enabled, &s.show_clock, &s.clock_24h, &s.home_on_standby}; for (int i = 0; i < 5; ++i) { if (!obj[flags[i]].is()) return false; *flag_values[i] = obj[flags[i]].as(); } const char *numbers[] = {"standby_seconds", "brightness", "standby_brightness", "night_start", "night_end", "night_brightness"}; int32_t *values[] = {&s.standby_seconds, &s.brightness, &s.standby_brightness, &s.night_start, &s.night_end, &s.night_brightness}; for (int i = 0; i < 6; ++i) { if (!obj[numbers[i]].is() || obj[numbers[i]].is()) return false; *values[i] = obj[numbers[i]].as(); } return s.valid(); } inline std::function detail, detail_update; // One page of a picker's names (op "options", app 0.2.83+), for the light's effects page. inline std::function &&)> options_received; struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}, *unit{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{}; // Wide cards span both columns; custom cards (clock, forecast, graph) draw into `extra`. // Full (firmware 0.2.62+) takes the whole page: the double-width card's head on top, a control at the bottom. // `base_y`: the card's row in the profile; without the page bar (firmware 0.2.69+) place_page moves it down. bool wide=false, full=false; int base_width=0, base_height=0, base_y=0; const lv_font_t *title_font{}; // `extra_full`: the size the parts were built for; a slot that changes between full and double width rebuilds them. lv_obj_t *extra{}; std::string extra_mode; bool extra_full=false; std::array parts{}; lv_point_precise_t *points{}; // Analog clock: centre and radius of the dial, so the second hand can move without a card redraw. int hand_cx=0, hand_cy=0, hand_r=0, hand_width=1; // A media tile over the whole page (firmware 0.2.64+): the width of its progress bar, so the fill can run once a // second without a card redraw. int media_bar_w=0; // Soft area under a polyline (graph, sun path), painted by the extra container's draw event. const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0; // Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill, // a slider or a toggle. Objects are rebuilt only when the control set changes. lv_obj_t *panel{}; std::string panel_mode; bool panel_dirty=false, panel_full=false; int panel_w=0; std::array keys{}, key_icons{}; std::array key_commands{}; std::array key_args; std::array key_checked{}; lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1; lv_color_t panel_accent{}, panel_text{}; // Busy sheet: a translucent white cover with a small spinner while a command is under way. lv_obj_t *busy{}, *spinner{}; bool busy_drawn=false; // Page fill skeleton: a sheet in the card's colour over its contents until the card is drawn. lv_obj_t *veil{}; }; constexpr unsigned POINT_BUFFER = 128; inline std::array widgets; // All icon fonts carry the same generated glyph set, so the first bound one answers for all. inline bool has_icon_glyph(uint32_t codepoint) { for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); } return false; } // Whether `font` draws the glyph a label's UTF-8 text starts with (a four-byte Material Design icon). inline bool font_has(const lv_font_t *font, const std::string &utf8) { if (!font || utf8.size() < 4) return false; const auto *b = reinterpret_cast(utf8.data()); uint32_t cp = (uint32_t(b[0] & 7) << 18) | (uint32_t(b[1] & 0x3F) << 12) | (uint32_t(b[2] & 0x3F) << 6) | (b[3] & 0x3F); lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(font, &dsc, cp, 0); } // Home Assistant's API link as ESPHome itself tracks it: gone the moment the socket drops, // back the moment HA reconnects, no guessing from message age. inline bool ha_connected() { return esphome::api_is_connected(); } // The manager repeats the whole layout every keepalive; one missed round plus its 20 s // loop slack and the sending itself are tolerated before the feed counts as gone. inline bool feed_alive() { return esphome::millis() - last_received < keepalive_seconds * 2000 + 60000; } inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); } // A dropped tap is logged with its reason, so a missed touch can be read from the ESPHome log // instead of guessed: moved too far, too short, already used by this contact, or bounce. inline bool allowed(uint32_t now, int tile, const std::string &what) { if (cyd::touch_guard.accept(now, tile)) return true; ESP_LOGI("touch", "tap on %s ignored: %s", what.c_str(), cyd::touch_guard.reason().c_str()); return false; } inline float number(JsonVariant value, float fallback = NAN) { if (!value.is() && !value.is()) return fallback; float n = value.as(); return std::isfinite(n) ? n : fallback; } inline std::string string(JsonVariant value, size_t maximum = 160) { if (!value.is()) return {}; std::string s = value.as(); if (s.size() > maximum) { while (maximum > 0 && (static_cast(s[maximum]) & 0xC0) == 0x80) --maximum; s.resize(maximum); } return s; } inline std::string list(JsonVariant value) { if (!value.is()) return {}; std::string out; serializeJson(value, out); return out.size() <= 512 ? out : ""; } inline std::string receive(const std::string &payload) { if (!enabled) return "Use the Easy Setup profile"; if (payload.size() > 4096) return "Error: message too large"; std::string result = "Error: invalid message"; esphome::json::parse_json(payload, [&](JsonObject root) -> bool { if (root["v"].as() != 1) { result = "Error: protocol version"; return false; } auto op = string(root["op"]); if (op == "layout") { if (!root["entities"].is() || !root["title"].is()) return false; auto settings = screen_settings::current; if (!root["settings"].isNull() && (!root["settings"].is() || !parse_settings(root["settings"].as(), settings))) { result = "Error: screen settings"; return false; } std::vector entities; for (JsonVariant entity : root["entities"].as()) { if (!entity.is() || entities.size() == MAX_TILES) return false; entities.push_back(entity.as()); } if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is())return false; if(!root["auto_home"].isNull() && !root["auto_home"].is())return false; if(!root["auto_home_seconds"].isNull() && (!root["auto_home_seconds"].is() || root["auto_home_seconds"].as()<30 || root["auto_home_seconds"].as()>3600))return false; if(!root["rotation"].isNull() && (!root["rotation"].is() || root["rotation"].as()>270 || root["rotation"].as()%90!=0))return false; // The clock and the number format of Settings -> Language & region (app 0.2.90), the same for every screen. if(!root["clock_24h"].isNull() && !root["clock_24h"].is())return false; if(!root["numbers"].isNull() && (!root["numbers"].is() || screen_text::number_style_of(root["numbers"].as())<0))return false; if(!root["keepalive"].isNull() && (!root["keepalive"].is() || root["keepalive"].as()<5 || root["keepalive"].as()>3600))return false; // Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers. std::vector positions; if (!root["slots"].isNull()) { if (!root["slots"].is()) return false; for (JsonVariant slot : root["slots"].as()) { if (!slot.is() || slot.as() >= MAX_SLOTS || positions.size() == MAX_TILES) return false; positions.push_back(static_cast(slot.as())); } } inbox = string(root["inbox"], 160); bool changed = false, moved = false, was_configured = model.configured; const std::string previous_title = model.title; const uint8_t previous_pages = model.pages; if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) { if (!model.refusal.empty()) { ESP_LOGW("runtime", "Layout of %u tiles refused: %s", static_cast(entities.size()), model.refusal.c_str()); result = model.refusal; } return false; } // Empty pages the user keeps on purpose; absent on older managers. model.pages = root["pages"].is() ? static_cast(std::clamp(root["pages"].as(), 1, MAX_PAGES)) : 1; if(root["swipe_pages"].is() && swipe_pages!=root["swipe_pages"].as()){ swipe_pages=root["swipe_pages"].as();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved); } if((root["auto_home"].is() && auto_home!=root["auto_home"].as()) || (root["auto_home_seconds"].is() && auto_home_seconds!=(int32_t)root["auto_home_seconds"].as())){ if(root["auto_home"].is())auto_home=root["auto_home"].as(); if(root["auto_home_seconds"].is())auto_home_seconds=(int32_t)root["auto_home_seconds"].as(); HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home); } bool rotation_changed=false; if(root["clock_24h"].is())settings.clock_24h=root["clock_24h"].as()?1:0; bool format_changed=false; if(root["numbers"].is()){ int style=screen_text::number_style_of(root["numbers"].as()); if(style!=screen_text::number_style){screen_text::number_style=(uint8_t)style;uint32_t saved=(uint32_t)style;numbers_preference.save(&saved);format_changed=true;} } if(rotation_supported && root["rotation"].is() && rotation!=root["rotation"].as()){ rotation=root["rotation"].as();rotation_preference.save(&rotation);rotation_changed=true; } if (!(settings == screen_settings::current)) { screen_settings::current = settings; settings_preference.save(&settings); // ESPHome batches flash writes; no write on keepalive. rotation_changed=true; } if(rotation_changed && settings_changed)settings_changed(); if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); } if (moved) ESP_LOGI("runtime", "tiles moved: pages rearranged"); if (root["keepalive"].is()) keepalive_seconds = root["keepalive"].as(); layout_rev = string(root["rev"], 16); last_received = esphome::millis(); // A repeat of the layout on screen (the hourly repeat, a save that changed nothing here) only // refreshes the feed: drawing the whole page again stalls touch input for a few hundred ms, // long enough to spoil a slider drag. The tile states that follow redraw their own tiles. if (changed || moved || !was_configured || rotation_changed || format_changed || model.pages != previous_pages || model.title != previous_title) { if (layout_changed) layout_changed(); refresh_all(); } // A repeat of the same layout keeps the inbox state as it is: no new recorder row. result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles"; return true; } if (op == "ping") { // Keepalive without content (app 0.2.39+): only the revision of the layout the manager holds. if (!root["rev"].is()) return false; if(!root["keepalive"].isNull() && (!root["keepalive"].is() || root["keepalive"].as()<5 || root["keepalive"].as()>3600))return false; if (root["keepalive"].is()) keepalive_seconds = root["keepalive"].as(); last_received = esphome::millis(); if (!model.configured || layout_rev != string(root["rev"], 16)) { result = "Resend needed"; return true; } result = model.ready() ? "Synced" : "Loading tiles"; return true; } if (op == "header") { // Validate every item before replacing the bar; an icon these fonts lack is left out. if (!root["items"].is()) return false; header_bar::Bar next; for (JsonVariant value : root["items"].as()) { if (next.count == header_bar::MAX_ITEMS || !value.is()) return false; header_bar::Item item; item.kind = header_bar::kind(string(value["k"], 8)); if (item.kind == header_bar::Kind::none) return false; uint32_t icon = tile_icon::codepoint(string(value["i"], 8)); item.icon = icon && has_icon_glyph(icon) ? icon : 0; item.text = string(value["t"], header_bar::TEXT_BYTES); item.epoch = value["e"].is() ? value["e"].as() : 0; item.has_color = header_bar::color(string(value["c"], 8), item.color); if (item.kind == header_bar::Kind::ago && item.epoch <= 0) return false; next.items[next.count++] = item; } next.received = true; bool same = header.received && header.count == next.count; for (size_t i = 0; same && i < next.count; ++i) same = header.items[i] == next.items[i]; header = next; last_received = esphome::millis(); if (!same) refresh_header_only(); // The same status as a tile state, so a changing value never flips the inbox entity. result = model.ready() ? "Synced" : "Loading tiles"; return true; } if (op == "camera") { // A link to a camera's image (app 0.2.66+): the answer to camera_request ("full"), or an alert's image ("alert", // announced with an empty link before show_alert and sent again with the link), or the media card's cover // ("cover", app 0.2.77+). Only ESP Screens' own port. const std::string view = string(root["t"], 8), entity = string(root["e"], 120), url = string(root["u"], 240); if (!valid_entity(entity) || (view != "full" && view != "alert" && view != "cover")) return false; if (!url.empty() && url.rfind("http://", 0) != 0) return false; camera_answer(view, entity, url); result = model.ready() ? "Synced" : "Loading tiles"; return true; } if (op == "history") { // A detail card's history (app 0.2.59+), the answer to history_request. Checked whole before it replaces // the one the screen holds. History next; next.entity = string(root["entity"], 120); next.hours = root["hours"] | 0u; if (!valid_entity(next.entity) || (next.hours != 1 && next.hours != 24 && next.hours != 168)) return false; // An answer to an earlier question (another card, another range) would push out the one the card waits for. if (next.entity != history_asked_entity || next.hours != history_asked_hours) { result = model.ready() ? "Synced" : "Loading tiles"; return true; } next.start = root["start"] | 0u; next.end = root["end"] | 0u; next.offset = std::clamp(root["off"] | 0, -14 * 3600, 14 * 3600); if (next.end <= next.start) return false; next.line = string(root["kind"], 12) != "timeline"; if (root["xt"].is()) for (JsonVariant moment : root["xt"].as()) { if (next.times.size() == 8) break; if (moment.is()) next.times.push_back(moment.as()); } if (next.line) { unsigned i = 0; if (root["values"].is()) for (JsonVariant value : root["values"].as()) { if (i == history_view::PARTS) break; float v = number(value); next.has[i] = std::isfinite(v); next.values[i] = next.has[i] ? v : 0; ++i; } next.bottom = number(root["dom"][0], 0); next.top = number(root["dom"][1], 1); if (!(next.top > next.bottom)) next.top = next.bottom + 1; if (root["yt"].is()) for (JsonVariant tick : root["yt"].as()) { if (next.ticks.size() == 6) break; float v = number(tick[0]); if (std::isfinite(v)) next.ticks.emplace_back(v, string(tick[1], 16)); } next.high = number(root["hi"][0]); next.has_high = std::isfinite(next.high); next.high_at = root["hi"][1] | 0u; next.low = number(root["lo"][0]); next.has_low = std::isfinite(next.low); next.low_at = root["lo"][1] | 0u; next.decimals = std::clamp(root["dec"] | 1, 0, 4); next.unit = string(root["unit"], 16); } else { next.slots = static_cast(std::clamp(root["slots"] | 96u, 1u, 96u)); if (root["states"].is()) for (JsonVariant state : root["states"].as()) { if (next.states.size() == 7) break; HistoryState s; s.label = string(state[0], 24); s.color = std::strtoul(string(state[1], 8).c_str(), nullptr, 16); s.seconds = state[2] | 0u; next.states.push_back(std::move(s)); } if (root["seg"].is()) for (JsonVariant item : root["seg"].as()) { if (next.runs.size() == 96) break; const unsigned slot = item[0] | 0u; const int state = item[1] | -1; if (slot >= next.slots || state >= static_cast(next.states.size()) || state < -1) return false; history_view::Run run; run.slot = static_cast(slot); run.state = static_cast(state); run.begin = item[2] | 0u; run.end = std::max(run.begin, item[3] | 0u); run.seconds = item[4] | 0u; next.runs.push_back(run); } if (root["words"].is()) for (JsonVariant pair : root["words"].as()) { if (next.words.size() == 12) break; next.words.emplace_back(string(pair[0], 32), string(pair[1], 24)); } next.began = root["began"] | 0u; next.active = root["active"] | -1; if (next.active >= static_cast(next.states.size())) next.active = -1; } history = std::move(next); history_received_at = esphome::millis(); history_answered = true; history_received(); result = model.ready() ? "Synced" : "Loading tiles"; return true; } if (op == "options") { // The names a picker on a light's effects page asked for (options_request), one page per message. std::string entity = string(root["e"], 120); if (!valid_entity(entity) || !root["o"].is()) return false; std::vector names; for (JsonVariant name : root["o"].as()) { if (names.size() == effects_page::MAX_NAMES) break; std::string text = string(name, 48); if (!text.empty()) names.push_back(std::move(text)); } if (options_received) options_received(entity, root["i"] | 0u, root["n"] | 1u, std::move(names)); result = model.ready() ? "Synced" : "Loading tiles"; return true; } #ifdef SWIPE_PROFILE if (op == "swipe_test") { // Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back` // (ms) swipes straight back after each one, like a quick second swipe. swipe_test(root["n"] | 20u, root["ms"] | 1200u, root["back"] | 0u, root); result = "Swipe test started"; return true; } #endif if (op != "state" || !root["i"].is() || !root["a"].is()) return false; unsigned index = root["i"].as(); std::string entity = string(root["entity"], 120); if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; } Tile &tile = model.tiles[index]; auto a = root["a"].as(); // The fingerprint of state, attributes and extras (a vacuum's mode or water select changes only // there), hashed while serializing so no copy of the message stays behind. Fingerprint revision; revision.add(string(root["state"])); revision.write('\n'); serializeJson(a, revision); if (!root["x"].isNull()) serializeJson(root["x"], revision); bool was_confirmed=tile.confirmed; tile.observe(revision.value); if(tile.pending && !tile.local_feedback && !was_confirmed && tile.confirmed) ESP_LOGI("runtime_action","HA state received entity=%s elapsed=%u ms",entity.c_str(),(unsigned)(esphome::millis()-tile.pending_since)); auto options = root["o"]; std::string background=string(options["background"],16); tile.background = tile_palette::color(background); tile.transparent = tile_palette::transparent(background); // An icon these fonts lack (a newer set than this firmware) keeps the domain icon. uint32_t icon = tile_icon::codepoint(string(options["icon"], 8)); tile.icon = icon && has_icon_glyph(icon) ? tile_icon::utf8(icon) : ""; tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto"; tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard"; tile.inline_control = string(options["inline"]); if (tile.inline_control.empty()) tile.inline_control="none"; // Direct controls (0.2.19+): the manager sends only the set a wide card really shows. tile.controls = string(options["controls"], 16); // Width arrives with the state, after the layout: re-pack the pages when it changes. bool was_wide = tile.wide, was_full = tile.full; std::string size = string(options["size"]); tile.full = size == "full"; tile.wide = tile.full || size == "wide"; bool repack = was_wide != tile.wide || was_full != tile.full; // Pre-computed extras: the manager converts time zones and fetches forecasts. What only some tiles // carry is collected in `next` and replaces the tile's Extra at the end (see Tile::set_extra). auto extra = root["x"]; Extra next; // A tap's own Home Assistant action (app 0.2.67+): {"s": action, "d": [[key, text]], "t": [[key, template]]}. auto act = options["act"]; if (act.is()) { std::string service = string(act["s"], 64); auto pairs = [](JsonVariant list, std::vector> &out) { if (!list.is()) return; for (JsonVariant pair : list.as()) { if (out.size() == 8 || !pair.is() || pair.as().size() != 2) continue; std::string key = string(pair[0], 32); if (!key.empty()) out.emplace_back(std::move(key), string(pair[1], 400)); } }; if (valid_action(service)) { next.action = std::move(service); pairs(act["d"], next.action_data); pairs(act["t"], next.action_templates); } } if (extra["days"].is()) for (JsonVariant day : extra["days"].as()) { if (next.forecast.size() == 5) break; next.forecast.emplace_back(); auto &f = next.forecast.back(); f.day = string(day["d"], 3); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]); f.rain = number(day["p"]); f.mm = number(day["r"]); } if (extra["hours"].is()) for (JsonVariant hour : extra["hours"].as()) { if (next.hours.size() == 8) break; next.hours.emplace_back(); auto &h = next.hours.back(); h.time = string(hour["t"], 5); h.condition = string(hour["c"], 20); h.temp = number(hour["h"]); h.rain = number(hour["p"]); h.mm = number(hour["r"]); } tile.last_run = extra["last"].is() ? extra["last"].as() : 0; next.sunrise = string(extra["rise"], 5); next.sunset = string(extra["set"], 5); next.timer_end = extra["end"].is() ? extra["end"].as() : 0; next.duration = string(extra["dur"], 16); next.remaining = string(extra["rem"], 16); tile.has_history=false; if (root["history"]["values"].is()) { tile.history.assign(24,NAN);unsigned j=0; for (JsonVariant value:root["history"]["values"].as()) { if(j==24) break; tile.history[j++]=number(value); } tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as(),1u,24u); } else if (!tile.history.empty()) { tile.history.clear(); tile.history.shrink_to_fit(); } if (a["options"].is()) for(JsonVariant option:a["options"].as()) { if(next.options.size()==8)break;next.options.push_back(string(option,48)); } tile.battery=number(a["battery_level"]);tile.volume=number(a["volume_level"]); tile.muted=a["is_volume_muted"].is() && a["is_volume_muted"].as(); tile.device_class=string(a["device_class"],24);next.hvac_action=string(a["hvac_action"],24); next.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as(); // The media card (firmware 0.2.64+, app 0.2.77+): the artist, the album, the track's length and position, and a // mark of the cover picture; an app from before sends none of them and the card shows what it has. next.media_artist=string(extra["artist"],80);next.media_album=string(extra["album"],80);next.media_picture=string(extra["pic"],16); next.media_duration=extra["dur"].is()?extra["dur"].as():0; next.media_position=extra["pos"].is()?extra["pos"].as():0; next.media_position_at=extra["at"].is()?extra["at"].as():0; tile.name = string(root["name"], 80); tile.state = string(root["state"], 160); tile.unit = string(a["unit_of_measurement"], 20); tile.brightness = number(a["brightness"]); tile.percentage = number(a["percentage"]); tile.slider_reported(esphome::millis()); tile.position = number(a["current_position"]); next.tilt = number(a["current_tilt_position"]); tile.current = number(a["current_temperature"]); tile.target = number(a["temperature"]); tile.humidity = number(a["current_humidity"]); tile.minimum = number(a["min_temp"], 7); tile.maximum = number(a["max_temp"], 35); tile.step = number(a["target_temp_step"], 0.5f); if(tile.domain()=="number" || tile.domain()=="input_number") { tile.minimum=number(a["min"],0);tile.maximum=number(a["max"],100);tile.step=number(a["step"],1); } if(tile.domain()=="weather") { tile.current=number(a["temperature"]);tile.unit=string(a["temperature_unit"],12); tile.humidity=number(a["humidity"]);next.wind=number(a["wind_speed"]);next.wind_unit=string(a["wind_speed_unit"],8);next.feels=number(a["apparent_temperature"]); } tile.modes = list(a["supported_color_modes"]); next.hvac_modes = list(a["hvac_modes"]); next.fan_modes = list(a["fan_modes"]); next.swing_modes = list(a["swing_modes"]); next.fan_mode = string(a["fan_mode"], 48); next.swing_mode = string(a["swing_mode"], 48); float hue = number(a["hs_color"][0]); float saturation = number(a["hs_color"][1]); tile.has_hs_color = std::isfinite(hue) && std::isfinite(saturation); tile.saturation = tile.has_hs_color ? std::lround(std::clamp(saturation, 0.0f, 100.0f)) : 0; if (std::isfinite(hue)) tile.hue = std::lround(std::clamp(hue, 0.0f, 360.0f)); float kelvin = number(a["color_temp_kelvin"]); if (std::isfinite(kelvin)) tile.kelvin = std::lround(std::clamp(kelvin, 1000.0f, 15000.0f)); tile.min_kelvin = std::clamp(number(a["min_color_temp_kelvin"], 0), 0.0f, 15000.0f); tile.max_kelvin = std::clamp(number(a["max_color_temp_kelvin"], 0), 0.0f, 15000.0f); next.fan_speed = string(a["fan_speed"], 48); if (a["fan_speed_list"].is()) for (JsonVariant speed : a["fan_speed_list"].as()) { if (next.fan_speeds.size() == 4) break; next.fan_speeds.push_back(string(speed, 48)); } // Vacuum rows (app 0.2.46+): the cleaning mode and water selects of the robot's device and the // suction speeds to offer, each at most six; the battery sensor when the vacuum has no attribute. // A chip just tapped keeps its choice while Home Assistant is still busy with it, so another update // of the robot (its battery, say) does not flip the row back for a moment. std::vector> tapped; if (tile.waiting(esphome::millis())) for (auto &c : tile.extra().choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent); auto choice = [&](const char *key, char kind) { auto c = extra[key]; if (!c["o"].is()) return; Choice row; row.kind = kind; row.entity = string(c["e"], 120); row.current = string(c["s"], 48); row.roles = string(c["r"], 6); if (kind != 's' && !valid_entity(row.entity)) return; for (JsonVariant value : c["o"].as()) { if (row.values.size() == 6) break; row.values.push_back(string(value, 48)); } if (c["l"].is()) for (JsonVariant label : c["l"].as()) { if (row.labels.size() == row.values.size()) break; row.labels.push_back(string(label, 24)); } while (row.labels.size() < row.values.size()) row.labels.push_back(row.values[row.labels.size()]); if (!row.values.empty()) next.choices.push_back(std::move(row)); }; if (tile.domain() == "vacuum") { choice("mode", 'm'); choice("water", 'w'); choice("fan", 's'); tile_controls::settle_suction(next); } for (auto &[kind, value] : tapped) if (auto *c = next.choice(kind)) if (c->current != value) c->sent = value; // A light's effects page (app 0.2.83+): the effect it runs, and the selects and numbers of its device. next.effect = string(a["effect"], 48); if (extra["rows"].is()) for (JsonVariant r : extra["rows"].as()) { if (next.option_rows.size() == 3) break; OptionRow row; row.entity = string(r["e"], 120); if (!valid_entity(row.entity)) continue; row.name = string(r["n"], 32); row.current = string(r["s"], 48); row.count = static_cast(std::min(r["c"] | 0u, 65535u)); row.icon = tile_icon::codepoint(string(r["i"], 8)); next.option_rows.push_back(std::move(row)); } if (extra["nums"].is()) for (JsonVariant r : extra["nums"].as()) { if (next.number_rows.size() == 2) break; NumberRow row; row.entity = string(r["e"], 120); if (!valid_entity(row.entity)) continue; row.name = string(r["n"], 32); row.value = number(r["v"]); row.low = number(r["lo"], 0); row.high = number(r["hi"], 100); row.step = number(r["st"], 1); if (!(row.high > row.low)) continue; row.icon = tile_icon::codepoint(string(r["i"], 8)); next.number_rows.push_back(std::move(row)); } if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]); next.charging = extra["chg"].is() && extra["chg"].as() == 1; next.room = string(extra["room"], 32); 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; tile.received = true; for(auto &w:widgets)if(w.index==index)w.cached_active=-1; last_received = esphome::millis(); if (repack && layout_changed) layout_changed(); refresh_tile(index); if (active_index == static_cast(index) && detail_update) detail_update(tile); refresh_detail(index); result = model.ready() ? "Synced" : "Loading tiles"; return true; }); return result; } // Between the manager's messages nothing redraws a card, so a command under way gets its own 200 ms LVGL timer: it // brings the busy sheet up once the grace has passed, takes it away as soon as Home Assistant answered or the wait ran // out, and deletes itself when no tile waits any more (firmware 0.2.59+). inline lv_timer_t *busy_timer{}; inline void end_wait(size_t index) { auto &t = model.tiles[index]; t.pending = false; t.undo_optimistic(); if (auto *x = t.extra_ptr()) for (auto &c : x->choices) c.sent.clear(); } inline void busy_watch(lv_timer_t *) { const uint32_t now = esphome::millis(); bool any = false; for (auto &w : widgets) { if (!w.tile || w.index >= model.count) continue; auto &t = model.tiles[w.index]; if (!t.pending) continue; if (!t.waiting(now) && !t.confirmed) end_wait(w.index); any = any || t.pending; if (t.loading(now) != w.busy_drawn) refresh_tile(w.index); } if (!any && busy_timer) { lv_timer_delete(busy_timer); busy_timer = nullptr; } } inline void watch_busy() { if (!busy_timer) busy_timer = lv_timer_create(busy_watch, 200, nullptr); } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES // Home Assistant answers an action sent with a call id (firmware 0.2.58+, Home Assistant 2025.10+). ESPHome keeps each // answer's callback until the answer arrives and has no timeout of its own, so a tap asks for at most four answers at a // time and ends the ones Home Assistant never gives (actions not allowed, an older Home Assistant) after eight seconds. // Only a refusal shows: the tile says Refused for a moment instead of waiting. struct WatchedCall { uint32_t id = 0, since = 0; }; inline std::array watched_calls{}; inline const char *const NO_ANSWER = "no answer"; inline void watch_call(esphome::api::HomeassistantActionRequest &request, const std::string &entity) { static uint32_t last_id = 0x5C000000u; // apart from ESPHome's own counter for YAML actions, which starts at 1 auto slot = std::find_if(watched_calls.begin(), watched_calls.end(), [](const WatchedCall &c) { return c.id == 0; }); if (slot == watched_calls.end()) return; const uint32_t id = ++last_id; *slot = {id, esphome::millis()}; request.call_id = id; esphome::api::global_api_server->register_action_response_callback(id, [id, entity](const esphome::api::ActionResponse &answer) { for (auto &c : watched_calls) if (c.id == id) c = {}; if (answer.is_success() || answer.get_error_message().c_str() == NO_ANSWER) { // "It worked" without a new state (a stop on a cover that already stands still) ends the wait in a moment // instead of running to the cap. if (answer.is_success()) for (size_t i = 0; i < model.tiles.size(); ++i) if (model.tiles[i].entity == entity && model.tiles[i].pending && !model.tiles[i].answered_at) model.tiles[i].answered_at = std::max(1, esphome::millis()); 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].undo_optimistic(); model.tiles[i].release_slider(); model.tiles[i].refused_at = std::max(1, esphome::millis()); refresh_tile(i); } }); } inline void expire_calls(uint32_t now) { for (auto &c : watched_calls) { if (!c.id || now - c.since < 8000) continue; const uint32_t id = c.id; c = {}; esphome::api::global_api_server->handle_action_response(id, false, esphome::StringRef(NO_ANSWER, 9)); } } #endif // Marks every tile of the entity busy and sends; `watch` asks Home Assistant for an answer (a tap). inline void send_action(esphome::api::HomeassistantActionRequest &request, const std::string &entity, bool watch) { for(size_t i=0;isend_homeassistant_action(request); } inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="", bool watch=true) { if (!fresh() || !valid_entity(entity)) return; esphome::api::HomeassistantActionRequest request; request.service = esphome::StringRef(service); request.data.init(key.empty() ? 1 : 2); esphome::api::HomeassistantServiceMap entry; entry.key = esphome::StringRef("entity_id"); 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);} 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 picker on a light's effects page asks the manager for its names (app 0.2.83+ answers with op "options"), one // page at a time. An event, like history_request. inline void options_request(const std::string &entity, unsigned page) { if (inbox.empty()) return; esphome::api::HomeassistantActionRequest request; request.service = esphome::StringRef("esphome.screen_options"); request.is_event = true; const std::string number = std::to_string(page); const std::string keys[] = {"inbox", "entity", "page"}, values[] = {inbox, entity, number}; request.data.init(3); for (int i = 0; i < 3; ++i) { esphome::api::HomeassistantServiceMap entry; entry.key = esphome::StringRef(keys[i]); entry.value = esphome::StringRef(values[i]); request.data.push_back(entry); } esphome::api::global_api_server->send_homeassistant_action(request); ESP_LOGI("effects", "Asked for the names of %s, page %u", entity.c_str(), page); } // A detail card asks the manager for its history (app 0.2.59+ answers with op "history"). An event, like // setting_event: it needs no permission to call Home Assistant actions. inline void history_request(const std::string &entity, uint32_t hours) { if (inbox.empty()) return; esphome::api::HomeassistantActionRequest request; request.service = esphome::StringRef("esphome.screen_history"); request.is_event = true; const std::string span = std::to_string(hours); const std::string keys[] = {"inbox", "entity", "hours"}, values[] = {inbox, entity, span}; request.data.init(3); for (int i = 0; i < 3; ++i) { esphome::api::HomeassistantServiceMap entry; entry.key = esphome::StringRef(keys[i]); entry.value = esphome::StringRef(values[i]); request.data.push_back(entry); } esphome::api::global_api_server->send_homeassistant_action(request); history_asked_at = esphome::millis(); history_answered = false; history_asked_entity = entity; history_asked_hours = hours; ESP_LOGI("history", "asked for %u h of %s", static_cast(hours), entity.c_str()); } inline void setting_event(const std::string &key, int value) { if(inbox.empty())return; esphome::api::HomeassistantActionRequest request;request.service=esphome::StringRef("esphome.screen_setting");request.is_event=true; std::string number=std::to_string(value);request.data.init(3); const std::string keys[]={"inbox","key","value"},values[]={inbox,key,number}; for(int i=0;i<3;++i){esphome::api::HomeassistantServiceMap entry;entry.key=esphome::StringRef(keys[i]);entry.value=esphome::StringRef(values[i]);request.data.push_back(entry);} esphome::api::global_api_server->send_homeassistant_action(request); } } // namespace runtime_tiles // Small shared native-LVGL detail cards. No images, canvas buffers or free scrolling. namespace runtime_tiles { inline lv_obj_t *detail_root=nullptr; inline unsigned detail_index=0; inline const lv_font_t *detail_font=nullptr; inline lv_obj_t *detail_actions[32]{}; inline unsigned detail_action_count=0; inline lv_obj_t *detail_status=nullptr; inline lv_obj_t *detail_badge_status=nullptr; inline lv_obj_t *detail_switch=nullptr; inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int y,int width) { auto *label=lv_label_create(parent);lv_label_set_text(label,text.c_str());lv_obj_set_pos(label,x,y);lv_obj_set_width(label,width); lv_obj_set_style_text_font(label,detail_font,0);lv_obj_set_style_text_color(label,theme::color(theme::INK),0); lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label; } inline std::string detail_state(const Tile &t){ if(t.domain()=="person")return t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state; if(t.domain()=="sun")return tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon); if(t.domain()=="timer")return timer_text(t); if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?tr(txt::script_running):last_run_text(t.last_run); if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))return tile_controls::binary_state_text(t.device_class,t.state=="on"); if(t.state=="on")return tr(txt::ha_on); if(t.state=="off")return tr(txt::ha_off); if(t.state=="docked")return tr(txt::ha_vacuum_docked); if(t.state=="cleaning")return tr(txt::ha_vacuum_cleaning); if(t.state=="paused")return tr(txt::ha_vacuum_paused); if(t.state=="returning")return tr(txt::ha_vacuum_returning); if(t.state=="idle")return tr(txt::ha_vacuum_idle); if(t.state=="error")return tr(txt::vacuum_check_robot); if(!t.available())return tr(txt::tile_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);} inline int slider_value(const Tile &t){ auto d=t.domain();float value=0; if(d=="light")value=std::isfinite(t.brightness)?t.brightness/255:0; if(d=="fan")value=std::isfinite(t.percentage)?t.percentage/100:0; // A blind's bar is the blind itself: the closed part is filled, so a closed cover is a full bar, as on its card and in // Home Assistant's own cover dialog (firmware 0.2.66+; before, the bar filled with the open part, the other way round). if(d=="cover")value=std::isfinite(t.position)?(100-t.position)/100:1; if(d=="media_player")value=std::isfinite(t.volume)?t.volume:0; if(d=="number"||d=="input_number") {char *end;float state=strtof(t.state.c_str(),&end);if(end!=t.state.c_str() && t.maximum>t.minimum)value=(state-t.minimum)/(t.maximum-t.minimum);} return std::clamp((int)std::lround(value*1000),0,1000); } inline lv_obj_t *captured_slider=nullptr; inline bool slider_changed=false; inline int slider_held=0; inline void slider_event(lv_event_t *e); inline void commit_slider(unsigned i,int raw){ if(i>=model.count || !fresh())return;auto &t=model.tiles[i];if(!t.available() || t.waiting(esphome::millis()))return; float value=std::clamp(raw,0,1000)/1000.0f;auto d=t.domain(); // A light's slider stops at 1 %, as in Home Assistant; tapping the card turns it off. // The slider stays where the finger left it while the light fades towards it (Tile::hold_slider). if(d=="light"){int sent=std::max(3,(int)std::lround(value*255));action("light.turn_on",t.entity,"brightness",std::to_string(sent));t.hold_slider(esphome::millis(),sent);} if(d=="fan"){int sent=(int)std::lround(value*100);action("fan.set_percentage",t.entity,"percentage",std::to_string(sent));t.hold_slider(esphome::millis(),sent);} if(d=="cover")action("cover.set_cover_position",t.entity,"position",std::to_string(100-(int)std::lround(value*100))); if(d=="media_player"){action("media_player.volume_set",t.entity,"volume_level",std::to_string(value));t.hold_slider(esphome::millis(),value);} if(d=="number"||d=="input_number") { if(!std::isfinite(t.minimum)||!std::isfinite(t.maximum)||t.maximum<=t.minimum||t.step<=0)return; value=std::clamp(t.minimum+std::round(value*(t.maximum-t.minimum)/t.step)*t.step,t.minimum,t.maximum); action(d+".set_value",t.entity,"value",std::to_string(value)); } } // Where the finger landed on a slider, the value it had, and whether it landed beside the strip (in the tile's lower // half that the strip claims, slider_zone) and moved: a press beside the strip that never moves is a tap on the tile. inline lv_point_t slider_press{0,0};inline int slider_press_value=0;inline bool slider_press_beside=false,slider_press_moved=false,slider_press_held=false; // The card a strip belongs to, for a press beside the strip that turns out to be the card's tap or hold. inline lv_obj_t *strip_card(lv_obj_t *slider){for(auto &w:widgets)if(w.slider==slider)return w.tile;return nullptr;} inline void slider_event(lv_event_t *e){ auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e); if(code==LV_EVENT_PRESSED){captured_slider=slider;slider_changed=false;slider_press_moved=false;slider_press_beside=false;slider_press_held=false; slider_press_value=lv_slider_get_value(slider); if(auto *indev=lv_indev_active()){lv_indev_get_point(indev,&slider_press);lv_area_t a;lv_obj_get_coords(slider,&a);slider_press_beside=slider_press.xa.x2 || slider_press.ya.y2;} // Beside the strip the card lights up as under a tap, until the finger starts dragging. if(slider_press_beside)if(auto *card=strip_card(slider))lv_obj_add_state(card,LV_STATE_PRESSED);} if(code==LV_EVENT_PRESSING && captured_slider==slider && !slider_press_moved){ if(auto *indev=lv_indev_active()){lv_point_t p;lv_indev_get_point(indev,&p);if(std::abs(p.x-slider_press.x)>=10 || std::abs(p.y-slider_press.y)>=10){slider_press_moved=true; if(auto *card=strip_card(slider))lv_obj_remove_state(card,LV_STATE_PRESSED);}}} // A finger resting beside the strip holds the card, as it would without the strip's claim on the card. if(code==LV_EVENT_LONG_PRESSED && captured_slider==slider && slider_press_beside && !slider_press_moved && !slider_press_held){ if(auto *card=strip_card(slider)){slider_press_held=true;lv_obj_send_event(card,LV_EVENT_LONG_PRESSED,nullptr);}} if((code==LV_EVENT_RELEASED || code==LV_EVENT_PRESS_LOST) && slider_press_beside)if(auto *card=strip_card(slider))lv_obj_remove_state(card,LV_STATE_PRESSED); // On release LVGL sets the value once more from the last touch point: log it when that throws a // dragged slider to one of its ends, so a stray touch sample shows up (cyd::release_jump). if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider){ auto *indev=lv_indev_active();int raw=lv_slider_get_value(slider); if(!indev || lv_indev_get_state(indev)!=LV_INDEV_STATE_RELEASED)slider_held=raw; else if(slider_changed && cyd::release_jump(slider_held,raw,lv_slider_get_min_value(slider),lv_slider_get_max_value(slider))){ lv_point_t point;lv_indev_get_point(indev,&point); ESP_LOGW("slider","Tile slider jumped on release: %d -> %d (touch x=%d y=%d)",slider_held,raw,(int)point.x,(int)point.y); } } // The range reaches below zero only to draw the round end at 0 (slider_handle). if(code==LV_EVENT_VALUE_CHANGED && lv_slider_get_value(slider)<0)lv_slider_set_value(slider,0,LV_ANIM_OFF); if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider)slider_changed=true; if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;} if(code==LV_EVENT_RELEASED && captured_slider==slider){ unsigned index=(uintptr_t)lv_event_get_user_data(e); // A slider among a card's own parts (the media tile's volume) belongs to the tile the slot shows now. for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider || (w.extra && lv_obj_get_parent(slider)==w.extra)){index=w.index;break;} bool changed=slider_changed;captured_slider=nullptr;slider_changed=false; // A finger that landed beside a tile's strip and let go without moving tapped the tile (or held it, sent above): // the value LVGL set from that point on release goes back, and the tile's own rules decide what the tap does. if(slider_press_beside && !slider_press_moved){ if(auto *card=strip_card(slider)){ lv_slider_set_value(slider,slider_press_value,LV_ANIM_OFF); ESP_LOGI("slider","%s beside the strip of %s: the tile's",slider_press_held?"Hold":"Tap",model.tiles[index].entity.c_str()); if(!slider_press_held)lv_obj_send_event(card,LV_EVENT_SHORT_CLICKED,nullptr); return;} } // A finger let go within the edge band of the glass meant the slider's end (cyd::edge_snap). if(auto *indev=lv_indev_active();indev && lv_obj_get_width(slider)>=lv_obj_get_height(slider)){ lv_point_t p;lv_indev_get_point(indev,&p);lv_area_t a;lv_obj_get_coords(slider,&a); int screen=lv_display_get_horizontal_resolution(lv_obj_get_display(slider)); int snap=cyd::edge_snap(p.x,a.x1,a.x2,screen,cyd::edge_snap_band); if(snap){int end=snap>0?(int)lv_slider_get_max_value(slider):std::max(0,(int)lv_slider_get_min_value(slider)); ESP_LOGI("slider","Let go %d px from the %s edge: slider %d -> %d",snap>0?screen-1-(int)p.x:(int)p.x,snap>0?"right":"left",lv_slider_get_value(slider),end); lv_slider_set_value(slider,end,LV_ANIM_OFF);changed=true;} } if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider)); } } inline void show_detail(unsigned index); // A chip on the vacuum card: the service call goes out, the chip shows the choice at once, and the card // waits for Home Assistant like after its other buttons. The card is drawn again once this tap's event // has finished, because a chosen mode can add or remove the suction and water rows. inline void choose(Tile &t,Choice &row,const std::string &value){ if(value==row.current)return; auto a=tile_controls::choice_action(t,row.kind,value);if(!a.valid())return; action(a.service,row.kind=='s'?t.entity:row.entity,a.key,a.value); row.sent=value;t.begin(esphome::millis()); lv_async_call([](void *){if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr); } // What a key on a card does; `cmd` is the key's command. Shared by the plain keys (detail_button) and the round // keys of the media card. -1 is Back. inline void detail_command(int cmd){ if(cmd==-1){hide_detail();return;} // History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes. if(cmd>=160&&cmd<163){ static const uint32_t hours[]={1,24,168}; if(detail_index=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return; auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return; if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);} // Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water. static const std::pair rows[]={{10,'s'},{50,'m'},{60,'w'}}; for(const auto &[first,kind]:rows) if(cmd>=first && cmdvalues.size())choose(t,*row,row->values[cmd-first]);} // The media card's keys (20-23); its volume slider goes through commit_slider. if(cmd>=20 && cmd<=24)media_action(t,cmd); if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]); // Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys). if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);} if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity); if(cmd==41)action("timer.cancel",t.entity); } inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){ auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height); lv_obj_set_style_bg_color(button,theme::color(command==0?theme::ACCENT:theme::BUTTON),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE); auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,theme::color(theme::ON_ACCENT),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(button,[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command); lv_obj_set_style_bg_color(button,theme::color(theme::ACCENT_PRESSED),LV_STATE_PRESSED); lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED); lv_obj_set_style_opa(button,LV_OPA_50,LV_STATE_DISABLED); if(command>=0 && detail_action_count<32)detail_actions[detail_action_count++]=button; return button; } // ---- Weather card: now, the next hours and the coming days, with rain ---- // The colour of a condition's icon, as this look draws it on a card. inline uint32_t weather_accent(const std::string &c) { using namespace theme::ha; if(c=="sunny")return theme::foreground(SUNNY); if(c=="clear-night")return theme::foreground(DEEP_PURPLE); if(c=="rainy"||c=="pouring"||c=="lightning-rainy"||c=="snowy-rainy")return theme::foreground(RAIN); if(c=="snowy"||c=="hail")return theme::foreground(SNOW); if(c=="lightning")return theme::foreground(LIGHTNING); if(c=="partlycloudy")return theme::foreground(PARTLY_CLOUDY); return theme::foreground(CLOUDY); } inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int y,int width,const lv_font_t *font,lv_text_align_t align,uint32_t color){ auto *l=detail_label(parent,text,x,y,std::max(1,width));lv_obj_set_style_text_font(l,font,0);lv_obj_set_height(l,lv_font_get_line_height(font)); lv_obj_set_style_text_align(l,align,0);lv_obj_set_style_text_color(l,lv_color_hex(color),0);return l; } inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int y,int width,const lv_font_t *font,lv_text_align_t align,theme::Role role){ return detail_text(parent,text,x,y,width,font,align,theme::hex(role)); } // "30%", "30% · 1.7 mm" or "1.7 mm": whatever the provider reports; empty when dry. inline std::string rain_text(float chance,float mm,bool with_mm){ char b[32]; if(std::isfinite(chance) && chance>=0){ if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · ",(int)std::lround(chance));return b+screen_text::decimal(mm,1)+" mm";} snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b; } if(std::isfinite(mm) && mm>=0.05f)return screen_text::decimal(mm,mm<10?1:0)+" mm"; return ""; } inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; } inline lv_obj_t *detail_card(int x,int y,int w,int h){ auto *card=lv_obj_create(detail_root);lv_obj_remove_style_all(card);lv_obj_remove_flag(card,LV_OBJ_FLAG_SCROLLABLE);lv_obj_remove_flag(card,LV_OBJ_FLAG_CLICKABLE); lv_obj_set_pos(card,x,y);lv_obj_set_size(card,w,h); lv_obj_set_style_bg_color(card,theme::color(theme::CARD),0);lv_obj_set_style_bg_opa(card,LV_OPA_COVER,0); lv_obj_set_style_radius(card,lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN),0); lv_obj_set_style_border_width(card,1,0);lv_obj_set_style_border_color(card,theme::color(theme::LINE),0); return card; } // Two cards: "now" with the next hours, and the coming days. Bold highs, muted lows, // rain in blue with a drop, so the eye finds temperature first and rain second. inline void render_weather_detail(const Tile &t,bool large,int width,int height,int pad){ const Extra &weather=t.extra(); if(detail_status){lv_obj_add_flag(detail_status,LV_OBJ_FLAG_HIDDEN);detail_status=nullptr;} const lv_font_t *big=watch_value_font?watch_value_font:detail_font; const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:detail_font; const lv_font_t *mini=mini_icon_font?mini_icon_font:icon_font; const lv_font_t *tiny=watch_icon_font?watch_icon_font:mini; const lv_font_t *small=widgets[0].value?lv_obj_get_style_text_font(widgets[0].value,LV_PART_MAIN):detail_font; const uint32_t ink=theme::hex(theme::INK),muted=theme::hex(theme::SUBTLE),rain=theme::foreground(theme::ha::RAIN); int text_h=lv_font_get_line_height(detail_font),small_h=lv_font_get_line_height(small),mini_h=lv_font_get_line_height(mini),tiny_h=lv_font_get_line_height(tiny); int icon_h=lv_font_get_line_height(icon_font),big_h=lv_font_get_line_height(big),hero=std::max(icon_h,big_h); int card_pad=large?14:7,inner=width-2*pad-2*card_pad; unsigned columns=std::min(weather.hours.size(),6); int hours_h=columns?small_h+mini_h+text_h+small_h+(large?16:6):0; int card_a_h=card_pad+hero+(columns?(large?14:8)+hours_h:0)+card_pad; // The Guition starts below the round back button of the top bar (60 px at 16); the CYD needs every pixel for the // coming days and starts where it did. int y=large?84:38; auto *now=detail_card(pad,y,width-2*pad,card_a_h); // Now: icon, temperature, condition, then feels-like / humidity / wind in one muted line. int cy=card_pad;char b[48]; detail_text(now,t.available()?weather_icon(t.state):"\U000F0595",card_pad,cy+(hero-icon_h)/2,icon_h+8,icon_font,LV_TEXT_ALIGN_LEFT,weather_accent(t.state)); int temp_x=card_pad+icon_h+(large?14:6),temp_w=large?92:50; detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink); int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x; int lines_h=text_h+small_h+(large?2:0); detail_text(now,t.available()?weather_text(t.state):tr(txt::tile_unavailable),text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink); std::string details; if(std::isfinite(weather.feels))details=fill(txt::weather_feels_like,"n",(int)std::lround(weather.feels)); if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);} if(std::isfinite(weather.wind)){snprintf(b,sizeof(b),"%.0f %s",weather.wind,weather.wind_unit.empty()?"km/h":weather.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);} detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted); // Next hours inside the same card: time, icon, temperature, rain per column. if(columns){ int hy=cy+hero+(large?14:8),col=inner/(int)columns; for(unsigned i=0;i((int)row.values.size(),6);if(!n)return; auto *bar=detail_shape(detail_root,x,y,w,h,track,h/2); if(border){lv_obj_set_style_border_width(bar,1,0);lv_obj_set_style_border_color(bar,theme::color(theme::LINE),0);} int inset=std::max(3,h/12),room=w-2*inset,words=0;int widths[6]; for(int i=0;i24) detail_text(detail_root," · "+t.extra().room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,theme::MUTED); if(battery)vacuum_power(detail_root,t,pad+inner-power_w,y,text,false); y+=line+(gap+4); } // Start (or pause, or resume) as the one blue button, dock beside it. int action_h=large?54:(small_hero?40:36),start_w=large?(inner-gap)*2/3:(inner-gap)/2; vacuum_command(cleaning?"\U000F03E4":"\U000F040A",tr(cleaning?(large?txt::vacuum_pause_cleaning:txt::vacuum_pause):paused?txt::vacuum_resume:(large?txt::vacuum_start_cleaning:txt::vacuum_clean)), pad,y,start_w,action_h,cleaning?1:0,true,text,icons,large?radius:action_h/2); vacuum_command("\U000F05F8",tr(txt::vacuum_dock),pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2); y+=action_h+gap; if(!mode && !suction && !water){ auto *note=detail_text(detail_root,tr(txt::vacuum_auto_suction),pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note; return; } // How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card. int mode_h=large?48:34,row_h=large?44:32,step=large?10:6,edge=large?14:0,icon_w=large?40:26; int note_h=lv_font_get_line_height(text); int block=(mode?mode_h:0)+(suction?(mode?step:0)+row_h:0)+(water?((mode||suction)?step:0)+row_h:0)+(automatic?step+note_h+step:0); const uint32_t track=theme::hex(large?theme::TRACK:theme::CARD); if(large)detail_card(pad,y,inner,block+2*edge); int x=pad+edge,w=inner-2*edge; y+=edge; if(mode){vacuum_segments(t,*mode,50,x,y,w,mode_h,track,!large,text);y+=mode_h+step;} auto level=[&](const Choice &row,int first,const char *icon){ detail_text(detail_root,icon,x,y+(row_h-lv_font_get_line_height(icons))/2,icon_w,icons,LV_TEXT_ALIGN_LEFT,theme::SUBTLE); vacuum_segments(t,row,first,x+icon_w,y,w-icon_w,row_h,track,!large,text); y+=row_h+step; }; if(suction)level(*suction,10,"\U000F0210"); if(water)level(*water,60,"\U000F058C"); if(automatic){ bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos; detail_text(detail_root,tr(smart?txt::vacuum_robot_chooses:txt::vacuum_per_room),x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE); } } // ---- Cover card (firmware 0.2.50+): Home Assistant's cover dialog in the style of the vacuum and climate cards ---- // A tall slider per movement: the position as a blind hanging from the top (a fully open cover shows none of it) // and the tilt as a handle over slats, each with its value below, like Home Assistant's own sliders. Open, stop // and close as a row of pill keys under them; the key of the direction the cover moves is filled. A slider // sends its value when the finger lifts, and shows it while it moves. // Home Assistant's purple for covers; the track and the slats are its tints. inline constexpr uint32_t COVER_ACCENT = theme::ha::PURPLE; inline uint32_t cover_track(){return theme::tint(COVER_ACCENT,37);} inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);} inline lv_obj_t *cover_values[2]{}; inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):tr(txt::tile_unavailable);} inline void cover_slider_event(lv_event_t *e){ auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e); const bool tilt=(uintptr_t)lv_event_get_user_data(e)==1; if(code==LV_EVENT_VALUE_CHANGED){ // The range reaches past 0 and 1000 only to keep the handle inside the track. int raw=lv_slider_get_value(slider); if(raw<0||raw>1000){raw=std::clamp(raw,0,1000);lv_slider_set_value(slider,raw,LV_ANIM_OFF);} int percent=(int)std::lround(raw/10.0f); if(auto *value=cover_values[tilt?1:0])label(value,std::to_string(tilt?percent:100-percent)+"%"); return; } if(code!=LV_EVENT_RELEASED||detail_index>=model.count)return; auto &t=model.tiles[detail_index]; if(!fresh()||!t.available()||!cyd::touch_guard.accept_slider(esphome::millis(),390+(tilt?1:0)))return; int percent=(int)std::lround(std::clamp(lv_slider_get_value(slider),0,1000)/10.0f); if(tilt)action("cover.set_cover_tilt_position",t.entity,"tilt_position",std::to_string(percent)); else action("cover.set_cover_position",t.entity,"position",std::to_string(100-percent)); } // One slider. The position fills from the top by how far the cover is closed; the tilt has no fill and its // handle moves over slats drawn behind it, thicker towards the closed end as in Home Assistant. inline lv_obj_t *cover_slider(lv_obj_t *parent,int x,int y,int w,int h,float value,bool tilt){ int radius=std::max(8,w/7),handle_h=std::max(4,w/18),handle_w=tilt?w*3/5:w*2/5,inset=std::max(6,w/9); if(tilt){ auto *slats=detail_shape(parent,x,y,w,h,cover_track(),radius); const int count=10,pitch=(h-2*radius/2)/count; for(int i=0;i &keys,unsigned count,int x,int y,int w,int h,int gap,const lv_font_t *icons){ int key_w=count?(w-gap*((int)count-1))/(int)count:0; for(unsigned i=0;i keys,tilt_keys; unsigned key_count=card.keys?tile_controls::cover_keys(t,keys):0,tilt_count=card.tilt_keys?tile_controls::cover_tilt_keys(t,tilt_keys):0; int rows=(key_count?1:0)+(tilt_count?1:0); int keys_y=bottom-rows*key_h-(rows>1?gap:0),top=large?108:72; int box_h=keys_y-gap-top; if(card.position||card.tilt){ detail_card(pad,top,inner,box_h); struct Part{bool tilt;float value;const char *caption;}; Part parts[2];int n=0; if(card.position)parts[n++]={false,t.position,tr(txt::cover_position)}; if(card.tilt)parts[n++]={true,t.extra().tilt,tr(txt::cover_tilt)}; int value_h=lv_font_get_line_height(big),caption_h=lv_font_get_line_height(text); auto percent=[](float value){return std::isfinite(value)?std::to_string((int)std::lround(std::clamp(value,0.0f,100.0f)))+"%":std::string("--");}; if(large){ // Columns: the slider with its value and name below. int edge=16,slider_h=box_h-2*edge-value_h-caption_h+4,slider_w=n==2?120:140,column_gap=48; int x=pad+(inner-(n*slider_w+(n-1)*column_gap))/2; for(int i=0;i(1,h.end-h.start))*(c.w-1); } // The history holds the open card's entity and range, and is new: the answer to this opening, or less than a // minute old (a card opened again shows it at once while it asks). inline bool history_fits(const Tile &t){ return history.entity==t.entity&&history.hours==history_hours&& (history_answered||esphome::millis()-history_received_at<60000); } // Home Assistant's word for the state now: from this entity's history words, else the card's own. The history can // still be the previous card's while this one waits for its answer. inline std::string history_words(const Tile &t){ if(!t.available())return tr(txt::tile_unavailable); if(history.entity==t.entity)for(const auto &pair:history.words)if(pair.first==t.state)return pair.second; return detail_state(t); } inline std::string history_clock(uint32_t epoch,bool weekday){ return history_view::clock(epoch,history.offset,screen_settings::current.clock_24h!=0,weekday); } // The soft area under the line: two triangles per segment down to the plot's bottom, no layer buffer. inline void history_fill(lv_event_t *e){ const auto &c=history_chart;if(!c.line||c.count<2||!c.area)return; auto *layer=lv_event_get_layer(e);lv_area_t area;lv_obj_get_coords(c.area,&area); lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=lv_color_hex(c.accent);dsc.opa=theme::fill_opacity(); const lv_value_precise_t ox=area.x1,oy=area.y1,base=area.y2+1; for(unsigned i=0;i+1=static_cast(h.states.size())?theme::hex(theme::TRACK):theme::state(h.states[state].color)); lv_area_t piece{x1,area.y1,std::max(x1,x2),area.y2};lv_draw_rect(layer,&dsc,&piece); } } inline void history_restore(){ auto &c=history_chart; if(c.value){label(c.value,c.value_text);lv_obj_set_style_text_color(c.value,theme::color(theme::INK),0);} if(c.first)label(c.first,c.first_text); if(c.second)label(c.second,c.second_text); } // A finger on the graph: the value (or state) of the moment under it, and when, at the top of the card. The graph // itself stays as it is. inline void history_scrub(lv_event_t *e){ auto &c=history_chart;const auto &h=history;auto code=lv_event_get_code(e); if(code==LV_EVENT_RELEASED||code==LV_EVENT_PRESS_LOST){history_restore();return;} if((code!=LV_EVENT_PRESSED&&code!=LV_EVENT_PRESSING)||!c.ready||!c.value||!lv_indev_active())return; lv_point_t point;lv_indev_get_point(lv_indev_active(),&point); const float fraction=std::clamp(float(point.x-c.x)/float(std::max(1,c.w)),0.0f,1.0f); const bool week=h.hours==168; if(c.line){ // A part without a value (before the sensor existed, while it was unavailable) says so. const int i=history_view::part_at(fraction); const uint64_t span=h.end-h.start; const uint32_t begin=h.start+static_cast(span*i/history_view::PARTS),finish=h.start+static_cast(span*(i+1)/history_view::PARTS); label(c.value,h.has[i]?history_view::number(h.values[i],h.decimals,h.unit):std::string(tr(txt::history_no_data))); lv_obj_set_style_text_color(c.value,lv_color_hex(h.has[i]?theme::foreground(c.accent):theme::hex(theme::SUBTLE)),0); if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,false)); if(c.second)label(c.second,!h.has[i]?std::string():h.hours==1?std::string(tr(txt::history_average)):fill(txt::history_average_of,"duration",history_view::duration(finish-begin))); }else{ // A run reads the real times of its state, not the slots it covers: a door open for 5 minutes says 5 min. const int r=history_view::run_at(h.runs,h.slots,fraction);if(r<0)return; const auto &run=h.runs[r]; const uint32_t begin=h.start+run.begin,finish=h.start+run.end; label(c.value,run.state<0||run.state>=static_cast(h.states.size())?std::string(tr(txt::history_no_data)):h.states[run.state].label); if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,week&&finish-begin>=43200)); if(c.second)label(c.second,history_view::duration(run.seconds)); } } // The transparent area a finger scrubs, over the graph and a little around it; it keeps the finger while it drags. inline void history_touch(int x,int y,int w,int h){ auto *area=lv_obj_create(detail_root);lv_obj_remove_style_all(area);lv_obj_set_pos(area,x,y);lv_obj_set_size(area,w,h); lv_obj_add_flag(area,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(area,LV_OBJ_FLAG_PRESS_LOCK); lv_obj_remove_flag(area,LV_OBJ_FLAG_SCROLLABLE);lv_obj_remove_flag(area,LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr); } // Times below the graph: round clock times (weekdays for a week) where they fit, and "Now" at the end. inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){ const auto &h=history; const lv_font_t *bold=detail_font?detail_font:font; const int now_w=text_width(tr(txt::history_now),bold),gap=large?10:6; detail_text(detail_root,tr(txt::history_now),x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK); int last=x-1000; for(uint32_t at:h.times){ if(at<=h.start||at>=h.end)continue; const std::string words=h.hours==168?history_view::clock(at,h.offset,true,true).substr(0,3) :history_view::axis_clock(at,h.offset,screen_settings::current.clock_24h!=0); const int tw=text_width(words,font),cx=x+static_cast(std::lround(float(at-h.start)/float(h.end-h.start)*w)); const int left=std::max(x-(large?8:4),cx-tw/2); if(left<=last+gap||left+tw>x+w-now_w-gap)continue; detail_shape(detail_root,cx,y-(large?7:4),1,large?5:3,theme::TICK,0); detail_text(detail_root,words,left,y,tw+2,font,LV_TEXT_ALIGN_LEFT,theme::SUBTLE); last=left+tw; } } inline void render_history_line(bool large,int card_x,int card_y,int card_w,int card_h,const lv_font_t *small,float current){ auto &c=history_chart;const auto &h=history; // The value now can lie outside the history (the last hour is not in the statistics yet): the plot makes room. c.bottom=h.bottom;c.top=h.top; if(std::isfinite(current)&&(currentc.top)){ const float margin=(std::max(c.top,current)-std::min(c.bottom,current))*0.06f; if(current(std::lround(history_y(tick.first))); detail_shape(detail_root,c.x,ty,c.w,1,theme::GRID,0); detail_text(detail_root,tick.second,card_x+edge,ty-label_h/2,label_w+2,small,LV_TEXT_ALIGN_RIGHT,theme::SUBTLE); } // Through the middle of each part, from the left edge when the range begins with a value, to "Now" at the right // (the value now); a curve that never overshoots them, so the axis and the highest and lowest moment stay true. // The averages only reach the highest and lowest moment in their hour, so the line also runs through those two // moments: its peak and its valley are where the rings are. float xs[history_view::PARTS+4],ys[history_view::PARTS+4];unsigned n=0; for(int i=0;i=0)high_x=xs[kept]; if(std::isfinite(c.low)){ const int at=history_view::through(xs,ys,n,history_view::PARTS+4,low_x,history_y(c.low)-c.y,snap,kept); if(at>=0)low_x=xs[at]; } } if(!c.points)c.points=new lv_point_precise_t[POINT_BUFFER]; c.count=n>=2?history_view::monotone(xs,ys,n,4,c.points,POINT_BUFFER):0; c.area=lv_obj_create(detail_root);lv_obj_remove_style_all(c.area);lv_obj_set_pos(c.area,c.x,c.y);lv_obj_set_size(c.area,c.w,c.h); lv_obj_remove_flag(c.area,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(c.area,LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(c.area,history_fill,LV_EVENT_DRAW_MAIN,nullptr); if(c.count>=2){ auto *stroke=lv_line_create(detail_root);lv_obj_remove_flag(stroke,LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_line_rounded(stroke,true,0);lv_obj_set_style_line_width(stroke,large?3:2,0); lv_obj_set_style_line_color(stroke,lv_color_hex(c.accent),0); lv_line_set_points(stroke,c.points,c.count);lv_obj_set_pos(stroke,c.x,c.y); } // The highest and lowest moment as rings; the Guition writes their values beside them. const int ring=large?12:8; auto marker=[&](float value,float at_x,bool high){ const int mx=c.x+static_cast(std::lround(at_x)); const int my=static_cast(std::lround(history_y(value))); auto *o=detail_shape(detail_root,mx-ring/2,my-ring/2,ring,ring,theme::CARD,ring/2); lv_obj_set_style_border_width(o,large?3:2,0);lv_obj_set_style_border_color(o,lv_color_hex(c.accent),0); if(!large)return; const std::string words=history_view::number(value,h.decimals,""); const int lh=lv_font_get_line_height(detail_font),tw=text_width(words,detail_font)+2; int ly=high?my-ring/2-lh-2:my+ring/2+2; if(lyc.y+c.h+6)ly=my-ring/2-lh-2; const int lx=std::max(c.x,std::min(mx-tw/2,c.x+c.w-tw)); detail_text(detail_root,words,lx,ly,tw,detail_font,LV_TEXT_ALIGN_CENTER,theme::INK); }; if(std::isfinite(c.high))marker(c.high,high_x,true); if(std::isfinite(c.low)&&c.low!=c.high)marker(c.low,low_x,false); if(std::isfinite(current)){ const int size=large?12:8; auto *o=detail_shape(detail_root,c.x+c.w-1-size/2,static_cast(std::lround(history_y(current)))-size/2,size,size,c.accent,size/2); lv_obj_set_style_border_width(o,2,0);lv_obj_set_style_border_color(o,theme::color(theme::CARD),0); } history_times(c.x,c.w,c.y+c.h+(large?8:4),small,large); history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),card_h); } inline void render_history_timeline(bool large,int card_x,int card_y,int card_w,int card_h,const lv_font_t *small){ auto &c=history_chart;const auto &h=history; const int edge=large?16:8,label_h=lv_font_get_line_height(small),times_gap=large?8:4,legend_gap=large?16:6; const int row_h=label_h+(large?10:3),square=large?14:8; c.x=card_x+edge;c.w=card_w-2*edge;c.h=large?56:24; // Bar, times and legend as one block in the middle of the card; a legend that does not fit loses its last rows. const int above=c.h+times_gap+label_h+legend_gap; const int rows=std::max(1,std::min(static_cast((h.states.size()+1)/2),(card_h-2*edge-above+row_h-label_h)/row_h)); const int block=above+rows*row_h-(row_h-label_h); c.y=card_y+std::max(edge,(card_h-block)/2); auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,c.x,c.y);lv_obj_set_size(bar,c.w,c.h); lv_obj_remove_flag(bar,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(bar,LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(bar,history_bar,LV_EVENT_DRAW_MAIN,nullptr); history_times(c.x,c.w,c.y+c.h+times_gap,small,large); // Every state with its colour and its time, in two columns. const int top=c.y+above,col_w=c.w/2; const size_t shown=std::min(h.states.size(),static_cast(rows)*2); for(size_t i=0;i(i%2)*col_w,ly=top+static_cast(i/2)*row_h; detail_shape(detail_root,lx,ly+(label_h-square)/2,square,square,theme::state(h.states[i].color),large?4:2); const std::string time=history_view::duration(h.states[i].seconds); const int tw=text_width(time,small)+2,words_x=lx+square+(large?10:5),time_x=lx+col_w-(large?14:8)-tw; detail_text(detail_root,h.states[i].label,words_x,ly,std::max(8,time_x-words_x-6),small,LV_TEXT_ALIGN_LEFT,theme::INK); detail_text(detail_root,time,time_x,ly,tw,small,LV_TEXT_ALIGN_LEFT,theme::SUBTLE); } history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),c.y+c.h+(large?24:12)-card_y); } // The range: an hour, a day or a week, as one segmented row. It asks the manager, not Home Assistant, so // waiting for a command does not lock it. inline void history_ranges(int x,int y,int w,int h){ static const uint32_t hours[]={1,24,168}; const char *const words[]={tr(txt::history_range_hour),tr(txt::history_range_day),tr(txt::history_range_week)}; const lv_font_t *font=control_font?control_font:detail_font; auto *track=detail_shape(detail_root,x,y,w,h,theme::CARD,h/2); lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,theme::color(theme::LINE),0); const int inset=std::max(3,h/10),sw=(w-2*inset)/3; for(int i=0;i<3;++i){ const bool selected=history_hours==hours[i]; const int segment_w=i==2?w-2*inset-2*sw:sw; auto *segment=detail_button(words[i],x+inset+i*sw,y+inset,segment_w,h-2*inset,160+i); lv_obj_set_style_radius(segment,(h-2*inset)/2,0); lv_obj_set_style_bg_color(segment,theme::color(theme::ACCENT),0); lv_obj_set_style_bg_opa(segment,selected?LV_OPA_COVER:LV_OPA_TRANSP,0); lv_obj_set_style_bg_opa(segment,LV_OPA_COVER,LV_STATE_PRESSED); if(!selected)lv_obj_set_style_bg_color(segment,theme::color(theme::ACCENT_TINT),LV_STATE_PRESSED); button_words(segment,font,theme::hex(selected?theme::ON_ACCENT:theme::INK),segment_w-6); if(detail_action_count&&detail_actions[detail_action_count-1]==segment)--detail_action_count; } } inline void render_history_detail(const Tile &t,bool large,int width,int height,int pad){ auto &c=history_chart;const auto &h=history;auto d=t.domain(); history_forget(); // Asked once per opening and range; again after 30 s without an answer (see tick). if(history_asked_entity!=t.entity||history_asked_hours!=history_hours||(!history_fits(t)&&esphome::millis()-history_asked_at>30000)) history_request(t.entity,history_hours); c.ready=history_fits(t); const bool line=c.ready?h.line:history_line_guess(t); c.line=line;c.accent=tile_controls::accent(t); const lv_font_t *big=watch_value_font?watch_value_font:(watch_font?watch_font:detail_font); const lv_font_t *text=control_font?control_font:detail_font; const lv_font_t *small=small_font?small_font:detail_font; // The header: the value now (or the state), and at the right what the history says about it. char *end=nullptr;const float parsed=std::strtof(t.state.c_str(),&end); const bool numeric=end!=t.state.c_str()&&std::isfinite(parsed)&&t.available(); const float current=numeric?parsed:NAN; c.first_text.clear();c.second_text.clear(); if(line){ int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast(std::min(4,t.state.size()-dot-1)); if(c.ready)decimals=h.decimals; c.value_text=!t.available()?std::string(tr(txt::tile_unavailable)):numeric?history_view::number(current,decimals,t.unit):t.state; if(c.ready){ // The value now counts too: it can lie beyond the history (the running hour is not in the statistics yet). if(h.has_high){c.high=h.high;c.high_at=h.high_at;} if(h.has_low){c.low=h.low;c.low_at=h.low_at;} if(std::isfinite(current)&&std::isfinite(c.high)&¤t>c.high){c.high=current;c.high_at=h.end;} if(std::isfinite(current)&&std::isfinite(c.low)&¤t=0){ c.first_text=h.states[h.active].label+" \u00B7 "+history_view::duration(h.states[h.active].seconds); c.second_text=h.began?plural(txt::history_times,h.began):""; } } const int row=large?84:50,row_h=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text); int right=width-pad-(large?8:4); if(t.is_switch()){ const int sw=large?84:52,sh=large?46:28; detail_switch=lv_switch_create(detail_root); lv_obj_set_size(detail_switch,sw,sh);lv_obj_set_pos(detail_switch,right-sw,row+(row_h-sh)/2); lv_obj_set_style_bg_color(detail_switch,theme::color(theme::SWITCH_OFF),LV_PART_MAIN); lv_obj_set_style_bg_color(detail_switch,lv_color_hex(theme::ha::SWITCH_ON),static_cast(LV_PART_INDICATOR)|LV_STATE_CHECKED); lv_obj_set_style_bg_color(detail_switch,theme::color(theme::KNOB),LV_PART_KNOB); lv_obj_set_style_opa(detail_switch,LV_OPA_50,LV_STATE_DISABLED); if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED); lv_obj_add_event_cb(detail_switch,[](lv_event_t *e){ if(detail_index>=model.count)return; auto &tile=model.tiles[detail_index];auto *control=lv_event_get_target_obj(e); bool allowed=fresh() && tile.available() && !tile.waiting(esphome::millis()) && cyd::touch_guard.accept(esphome::millis(),350); bool requested_on=lv_obj_has_state(control,LV_STATE_CHECKED); // Only HA's reported state is authoritative, including a refused/failed command. if(tile.state=="on")lv_obj_add_state(control,LV_STATE_CHECKED);else lv_obj_remove_state(control,LV_STATE_CHECKED); if(allowed){tile.optimistic(requested_on);action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);} },LV_EVENT_VALUE_CHANGED,nullptr); if(detail_action_count<32)detail_actions[detail_action_count++]=detail_switch; right-=sw+(large?14:8); } // The value column fits the widest text a finger can show there, so a readout never runs into the texts beside it. int widest=text_width(c.value_text,big); if(c.ready)widest=std::max(widest,text_width(tr(txt::history_no_data),big)); if(c.ready&&line){for(int i=0;idata)return existing; auto *p=existing; if(!p){p=lv_image_create(parent);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);} lv_image_set_src(p,src);lv_obj_set_pos(p,r.x,r.y);lv_obj_set_size(p,r.w,r.h);lv_obj_invalidate(p); return p; #else (void)parent;(void)r;(void)src;return existing; #endif } // The bar's fill and the elapsed time follow the track: once a second from tick(), without a redraw. inline void media_progress(const Tile &t,lv_obj_t *fill,lv_obj_t *elapsed,int bar_w){ const auto &x=t.extra(); if(!fill || !x.media_duration)return; const bool play=media_card::playing(t.state); const int p=media_card::progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration); const int h=lv_obj_get_style_height(fill,LV_PART_MAIN); const int w=std::max(h,bar_w*std::max(0,p)/1000); if(lv_obj_get_style_width(fill,LV_PART_MAIN)!=w)lv_obj_set_width(fill,w); if(elapsed)label(elapsed,media_card::clock_text(media_card::elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))); } // The card: everything under the top bar, from `top` down. inline void render_media_detail(Tile &t,unsigned index,bool large,int width,int height,int top){ using namespace media_card; using namespace tile_controls; const auto &x=t.extra(); const Metrics m=media_metrics(large); const Layout l=layout(m,width,std::max(60,height-top-(large?12:6))); auto at=[&](Rect r){r.y+=top;return r;}; const bool usable=fresh()&&t.available(),track=usable&&has_track(t.state),play=media_card::playing(t.state); const uint32_t f=t.supported;auto can=[&](uint32_t bit){return usable&&(!f||(f&bit));}; // The cover, or its placeholder with the player's icon; the cover comes over it once the app served it. auto *frame=media_box(detail_root,nullptr,at(l.art),theme::tint(theme::ha::LIGHT_BLUE,51),l.art_radius); const std::string glyph=icon_for(t); const lv_font_t *placeholder_font=big_icon_font&&font_has(big_icon_font,glyph)?big_icon_font:tile_icon_font(); auto *icon=lv_label_create(frame);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_text_font(icon,placeholder_font,0); lv_obj_set_style_text_color(icon,theme::rgb(theme::icon(theme::ha::LIGHT_BLUE)),0);lv_label_set_text(icon,glyph.c_str());lv_obj_center(icon); media_art_rect=at(l.art);media_detail_picture=nullptr; const uint32_t ground=theme::hex(theme::PAGE); if(camera_supported()&&track&&!x.media_picture.empty()){ cover_want(t.entity,x.media_picture,l.art.w,ground,CoverOwner::DETAIL,0); media_detail_picture=media_picture_show(detail_root,nullptr,media_art_rect,cover_ready(t.entity,l.art.w,ground)); } // Title, artist · album. const lv_font_t *title_font=watch_font?watch_font:detail_font,*artist_font=control_font?control_font:detail_font,*small=small_font?small_font:detail_font; const lv_text_align_t align=l.wide?LV_TEXT_ALIGN_LEFT:LV_TEXT_ALIGN_CENTER; detail_text(detail_root,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),l.title.x,l.title.y+top,l.title.w,title_font,align,theme::INK); if(l.artist)detail_text(detail_root,track?subtitle(x.media_artist,x.media_album):std::string(),l.artist_line.x,l.artist_line.y+top,l.artist_line.w,artist_font,align,theme::MUTED); // The progress bar: the fill runs while the track plays; a stream without a length has no bar to show. media_progress_fill=nullptr;media_elapsed_label=nullptr;media_bar_width=l.bar.w; if(track && x.media_duration){ media_box(detail_root,nullptr,at(l.bar),theme::hex(theme::TRACK),LV_RADIUS_CIRCLE); Rect fill=at(l.bar);fill.w=std::max(l.bar.h,l.bar.w*std::max(0,progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))/1000); media_progress_fill=media_box(detail_root,nullptr,fill,media_accent(),LV_RADIUS_CIRCLE); if(l.times){ media_elapsed_label=detail_text(detail_root,clock_text(elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)),l.elapsed.x,l.elapsed.y+top,l.elapsed.w,small,LV_TEXT_ALIGN_LEFT,theme::SUBTLE); detail_text(detail_root,clock_text(x.media_duration),l.total.x,l.total.y+top,l.total.w,small,LV_TEXT_ALIGN_RIGHT,theme::SUBTLE); } } // The keys: previous, play or pause on the accent, next; the mute key bare at the start of the volume row. An off // player shows one power key instead, and no volume row: it reports no volume. auto cb=[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));}; const lv_font_t *key_font=mini_icon_font?mini_icon_font:detail_font; std::vector keys; if(usable && media_off(t)){ if(can(feature::MEDIA_TURN_ON))keys.push_back(media_key(detail_root,nullptr,at(l.play),glyph::POWER,tile_icon_font(),true,false,true,cb,(void*)(intptr_t)24)); }else{ keys={media_key(detail_root,nullptr,at(l.prev),glyph::PREVIOUS,key_font,false,false,can(feature::MEDIA_PREVIOUS),cb,(void*)(intptr_t)21), media_key(detail_root,nullptr,at(l.play),play?glyph::PAUSE:glyph::PLAY,tile_icon_font(),true,false,can(feature::MEDIA_PLAY|feature::MEDIA_PAUSE),cb,(void*)(intptr_t)20), media_key(detail_root,nullptr,at(l.next),glyph::NEXT,key_font,false,false,can(feature::MEDIA_NEXT),cb,(void*)(intptr_t)22)}; if(std::isfinite(t.volume)){ keys.push_back(media_key(detail_root,nullptr,at(l.mute),t.muted?glyph::MUTED:glyph::VOLUME,key_font,false,true,can(feature::MEDIA_VOLUME_MUTE),cb,(void*)(intptr_t)23)); media_slider(detail_root,nullptr,at(l.volume),t,large,can(feature::MEDIA_VOLUME_SET),(void*)(uintptr_t)index); detail_text(detail_root,media_volume_text(t),l.percent.x,l.percent.y+top,l.percent.w,small,LV_TEXT_ALIGN_RIGHT,theme::MUTED); } } // Only the keys the player supports join the card's actions: tick() enables those again after a wait, and a key // the player lacks stays faded. for(auto *k:keys)if(!lv_obj_has_state(k,LV_STATE_DISABLED) && detail_action_count<32)detail_actions[detail_action_count++]=k; } inline void show_detail(unsigned index){ if(index>=model.count)return; // A card that opens starts on a day (an hour for a tile whose graph shows one); switching ranges keeps it open. if(!detail_root||lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN)||detail_index!=index){ history_hours=model.tiles[index].history_hours==1?1:24;history_asked_entity.clear(); } detail_index=index;auto &t=model.tiles[index]; if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN); if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);} detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget(); media_progress_fill=nullptr;media_elapsed_label=nullptr;media_detail_picture=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root); lv_obj_set_style_bg_color(detail_root,theme::color(theme::PAGE),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0); int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default()); bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2; // The same top bar as the board's own cards: a round back arrow at the left, the name centred. int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8; auto *back=detail_button("",bar_x,bar_y,bar,bar,-1);lv_obj_set_style_radius(back,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(back,theme::color(theme::KEY),0); auto *arrow=lv_obj_get_child(back,0);if(mini_icon_font)lv_obj_set_style_text_font(arrow,mini_icon_font,0);lv_label_set_text(arrow,"\U000F004D");lv_obj_set_size(arrow,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(arrow); const lv_font_t *title_font=watch_font?watch_font:detail_font; auto *heading=detail_label(detail_root,t.name,bar_x+bar+8,bar_y+(bar-lv_font_get_line_height(title_font))/2,width-2*(bar_x+bar+8)); lv_obj_set_style_text_font(heading,title_font,0);lv_obj_set_height(heading,lv_font_get_line_height(title_font));lv_obj_set_style_text_align(heading,LV_TEXT_ALIGN_CENTER,0); auto d=t.domain(); std::string state=d=="cover"?cover_status_line(t):detail_state(t); // The vacuum and history cards draw their own state. const bool with_history=history_card(t); if(d!="vacuum"&&d!="media_player"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);} if(with_history){ render_history_detail(t,large,width,height,pad); }else if(d=="vacuum"){ render_vacuum_detail(t,large,width,height,pad); }else if(d=="cover"){ if(detail_status && control_font){lv_obj_set_style_text_font(detail_status,control_font,0);lv_obj_set_height(detail_status,lv_font_get_line_height(control_font));} render_cover_detail(t,large,width,height,pad); }else if(d=="select"||d=="input_select"){ const auto &options=t.extra().options; for(unsigned i=0;i= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", seconds / 3600, seconds / 60 % 60, seconds % 60); else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60); return b; } // "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep", in the screen's language; scripts and scenes have no // useful on/off. inline std::string month_short(const esphome::ESPTime &now); inline std::string last_run_text(uint32_t epoch) { if (!epoch) return tr(txt::script_never_run); auto when = esphome::ESPTime::from_epoch_local(epoch); auto now = now_time ? now_time() : esphome::ESPTime{}; if (!when.is_valid()) return tr(txt::script_never_run); char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute); if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return fill(txt::script_last_time, "time", clock); if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return fill(txt::script_yesterday_time, "time", clock); std::string date = fill(fill(txt::date_day_month, "day", std::to_string(when.day_of_month)), "month", month_short(when)); return fill(txt::script_last_date, "date", date); } inline std::string timer_text(const Tile &t) { const Extra &x = t.extra(); if (t.state == "active") return countdown(timer_left(x.timer_end, now_epoch(), x.duration)); if (t.state == "paused") return fill(txt::timer_paused_left, "time", countdown(duration_seconds(x.remaining))); return x.duration.empty() ? std::string(tr(txt::ha_off)) : countdown(duration_seconds(x.duration)); } inline std::string weekday_text(const esphome::ESPTime &now) { return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? tr(txt::date_weekdays + now.day_of_week - 1) : ""; } inline std::string month_short(const esphome::ESPTime &now) { return now.is_valid() && now.month >= 1 && now.month <= 12 ? tr(txt::date_months_short + now.month - 1) : ""; } // "Monday 14 September" in English, "maandag 14 september" in Dutch (screen.date.full). inline std::string date_text(const esphome::ESPTime &now) { if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return ""; std::string text = fill(txt::date_full, "weekday", weekday_text(now)); text = fill(text, "day", std::to_string(now.day_of_month)); return fill(text, "month", tr(txt::date_months + now.month - 1)); } // Where the finger is, or where it let go, in the screen's coordinates; false without a finger (a test's event). inline bool finger_at(lv_point_t &point) { auto *indev = lv_indev_active(); if (!indev || lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) return false; lv_indev_get_point(indev, &point); return true; } inline void event(lv_event_t *event) { auto &w = *static_cast(lv_event_get_user_data(event)); if (!enabled || w.index >= model.count) return; auto code = lv_event_get_code(event); if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return; // A finger that slid off the card is not a tap or a hold on it (the Guition, firmware 0.2.65+). LVGL keeps the press // on the object it started on (LV_OBJ_FLAG_PRESS_LOCK, which LVGL 9.5 sets on every child) and clicks it on release // wherever the finger is. The CYD's drift limit already drops such a tap; the Guition has none (TOUCH_MOVE_LIMIT_PX 0, // so a firm press that drifts still counts) and asks where the finger let go instead. The lock itself stays: without // it a finger that slides on to a slider would press and drag that slider. lv_point_t point; if (cyd::touch_guard.move_limit() <= 0 && finger_at(point) && !lv_obj_hit_test(w.tile, &point)) { ESP_LOGI("touch", "tap on %s ignored: %s outside the tile", model.tiles[w.index].entity.c_str(), code == LV_EVENT_LONG_PRESSED ? "held" : "let go"); return; } // The built-in cards need nothing from Home Assistant: the settings card opens the screen's own page with // the link down too, as its card says (firmware 0.2.49+), and the clock card does nothing under a finger. if (model.tiles[w.index].builtin()) { auto &card = model.tiles[w.index]; if (card.is_page()) { // A navigation tile goes to its page on a short tap, with the link down too; holding does nothing. if (code != LV_EVENT_SHORT_CLICKED || card.tap == "none" || card.page_target() < 1) return; if (allowed(esphome::millis(), 100 + w.index, card.entity)) go_to_page(card.page_target() - 1); return; } if (!card.is_settings() || card.tap == "none" || (settings_screen::may_open && !settings_screen::may_open())) return; if (allowed(esphome::millis(), 100 + w.index, card.entity)) settings_screen::open(); return; } if (!fresh()) return; if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return; auto &tile = model.tiles[w.index]; auto d = tile.domain(); // Scenes/scripts often have timestamps or 'off'; unavailable devices never act. if (!tile.available() || tile.waiting(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;} // 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: // On / off shows the new stand at once, as Home Assistant's switch does; other actions have nothing to show yet. if (tap.service == d + ".toggle" && (d == "light" || d == "switch" || d == "input_boolean" || d == "fan")) tile.optimistic(tile.state != "on"); 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; show_detail(w.index); return; case tile_controls::TapRoute::OVERLAY: active_index = w.index; tile.begin(esphome::millis(), true); if (detail) detail(tile); return; default: return; } } // The push that wakes a screen in standby only wakes it (dim_wake_overlay), with one exception (firmware 0.2.65+): on a // page that is one full tile which switches something on or off, the push also switches it, as a light switch on the // wall does in the dark. It is handed to the tile as its own tap, so it goes the way a tap on a lit screen goes (the // touch guard, the busy sheet, the new stand at once) and counts once. A push on the tile's slider or keys, the top bar // or the page bar only wakes, as does a flick. Called by both boards' dim_wake_overlay; true when it tapped the tile. inline bool wake_tap() { auto &w = widgets[0]; if (!enabled || !fresh() || !w.tile || !w.full || w.index >= model.count || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN)) return false; const auto &t = model.tiles[w.index]; const auto route = tile_controls::tap_route(t, false); if (t.builtin() || route.route != tile_controls::TapRoute::ACTION || route.service != t.domain() + ".toggle") return false; lv_point_t point; if (!finger_at(point) || lv_indev_search_obj(w.tile, &point) != w.tile) return false; lv_obj_send_event(w.tile, LV_EVENT_SHORT_CLICKED, nullptr); return true; } // 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). inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector) { lv_style_value_t current; if (lv_obj_get_local_style_prop(obj, prop, ¤t, selector) == LV_STYLE_RES_FOUND && lv_color_eq(current.color, color)) return; lv_style_value_t value{}; value.color = color; lv_obj_set_local_style_prop(obj, prop, value, selector); } inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector) { lv_style_value_t current; if (lv_obj_get_local_style_prop(obj, prop, ¤t, selector) == LV_STYLE_RES_FOUND && current.num == number) return; lv_style_value_t value{}; value.num = number; lv_obj_set_local_style_prop(obj, prop, value, selector); } // Card sliders follow Home Assistant's control slider (a 42 px track there): corners of 12 and // 8 px on track and fill, a white handle 4 px wide and half the height, an eighth of the height in // from the end of the fill, and a fill that never gets shorter than a third of the height. LVGL // centres the knob on the end of the fill, so the knob is narrowed and moved back; the range starts // below zero, so at 0 the fill is still that short stub holding the handle. Values below zero never // leave the slider (see slider_event). Small strips keep round ends: the corners would not show. inline int slider_handle_width(int height) { return height > 20 ? 4 : 2; } inline int slider_stub(int height) { return std::max(height / 3, 2 * std::max(1, height / 8) + slider_handle_width(height)); } inline void slider_handle(lv_obj_t *slider, int width, int height) { int handle = slider_handle_width(height), inset = std::max(1, height / 8) + handle / 2, half = height >> 1; // Track and fill share one radius: a fill rounded less than its track makes LVGL draw the fill into // a buffer of its own size on every redraw (15 KB on a CYD card), which the CYD's heap can't spare. int corner = height > 20 ? height * 12 / 42 : LV_RADIUS_CIRCLE; set_number(slider, LV_STYLE_RADIUS, corner, LV_PART_MAIN); set_number(slider, LV_STYLE_RADIUS, corner, LV_PART_INDICATOR); set_number(slider, LV_STYLE_PAD_LEFT, inset + handle / 2 - half, LV_PART_KNOB); set_number(slider, LV_STYLE_PAD_RIGHT, handle / 2 - inset - (height - half), LV_PART_KNOB); set_number(slider, LV_STYLE_PAD_TOP, -(height / 4), LV_PART_KNOB); set_number(slider, LV_STYLE_PAD_BOTTOM, -(height / 4), LV_PART_KNOB); int stub = slider_stub(height), below = width > stub ? 1000 * stub / (width - stub) : 0; if (lv_slider_get_min_value(slider) != -below || lv_slider_get_max_value(slider) != 1000) lv_slider_set_range(slider, -below, 1000); } // An off light or fan shows only the grey track, as in Home Assistant: no fill, no handle. inline void slider_bar(lv_obj_t *slider, bool shown) { set_number(slider, LV_STYLE_BG_OPA, shown ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_INDICATOR); set_number(slider, LV_STYLE_BG_OPA, shown ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_KNOB); } inline bool slider_bar_shown(const Tile &t, bool on) { auto d = t.domain(); return on || (d != "light" && d != "fan"); } inline void set_hidden(lv_obj_t *obj, bool hidden) { if (hidden) lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); } // A card's size as its styles request it. LVGL's own getters only follow after a layout pass, and // that pass walks every object on the screen, so the cards are laid out without asking for one. inline int tile_width(const Widgets &w) { int32_t width = lv_obj_get_style_width(w.tile, LV_PART_MAIN); return LV_COORD_IS_PX(width) ? width : lv_obj_get_width(w.tile); } inline int tile_height(const Widgets &w) { int32_t height = lv_obj_get_style_height(w.tile, LV_PART_MAIN); return LV_COORD_IS_PX(height) ? height : lv_obj_get_height(w.tile); } inline int content_width(const Widgets &w) { return tile_width(w) - lv_obj_get_style_space_left(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_right(w.tile, LV_PART_MAIN); } inline int content_height(const Widgets &w) { return tile_height(w) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN); } // A card's strip is thin (8 px on a CYD card), so the whole card belongs to it: a finger that lands above the strip // still drags it. A press that never moves taps or holds the card instead (slider_event), so nothing is lost. inline int slider_zone(const Widgets &w, int strip) { return std::max(8, (int) lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) + content_height(w) - strip); } inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) { lv_obj_update_layout(tile); // Compact cards need room for two text lines and a separate dimmer track. if(lv_obj_get_height(tile)<=80){lv_obj_set_style_pad_top(tile,4,0);lv_obj_set_style_pad_bottom(tile,4,0);} lv_obj_set_style_border_width(tile,1,0); lv_obj_update_layout(tile); widgets[index] = {tile, title, value, circle, icon, index}; auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN); w.base_width=lv_obj_get_width(tile);w.base_height=lv_obj_get_height(tile); w.base_y=lv_obj_get_y(tile); w.title_font=lv_obj_get_style_text_font(title,LV_PART_MAIN); w.icon_font=lv_obj_get_style_text_font(icon,LV_PART_MAIN); w.unit=lv_label_create(tile);lv_obj_set_style_text_font(w.unit,w.value_font,0);lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN); lv_label_set_long_mode(w.unit,LV_LABEL_LONG_DOT); // Fixed one-line boxes prevent wrapped names from overlapping the state on both boards. lv_obj_set_height(title,lv_font_get_line_height(lv_obj_get_style_text_font(title,LV_PART_MAIN))); lv_obj_set_height(value,lv_font_get_line_height(w.value_font)); lv_label_set_long_mode(title,LV_LABEL_LONG_DOT);lv_label_set_long_mode(value,LV_LABEL_LONG_DOT); w.progress=lv_obj_create(tile);lv_obj_remove_style_all(w.progress);lv_obj_set_size(w.progress,0,3);lv_obj_align(w.progress,LV_ALIGN_BOTTOM_LEFT,0,0);lv_obj_add_flag(w.progress,LV_OBJ_FLAG_HIDDEN); w.slider=lv_slider_create(tile);lv_obj_set_size(w.slider,lv_obj_get_width(tile)-24,lv_obj_get_height(tile)>80?28:10);lv_obj_align(w.slider,LV_ALIGN_BOTTOM_MID,0,0);lv_slider_set_range(w.slider,0,1000); // A short white bar inside the fill as handle, like the control sliders (invisible before 0.2.20). int strip=lv_obj_get_height(tile)>80?28:10; lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);lv_obj_set_style_bg_opa(w.slider,LV_OPA_COVER,LV_PART_KNOB); slider_handle(w.slider,lv_obj_get_width(tile)-24,strip); lv_obj_set_style_border_width(w.slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(w.slider,0,LV_PART_KNOB); // Track and fill follow the card's palette (render_slot), which is drawn before the slider shows. lv_obj_set_style_radius(w.slider,2,LV_PART_KNOB); lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_GESTURE_BUBBLE); lv_obj_add_event_cb(w.slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index); lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]); lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]); } inline void label(lv_obj_t *obj, const std::string &text) { if (text != lv_label_get_text(obj)) lv_label_set_text(obj, text.c_str()); } // lv_obj_set_style_* always invalidates the object; these only do so on a real change, // which keeps a one-second clock tick or a busy spinner from redrawing whole cards. inline void set_font(lv_obj_t *obj, const lv_font_t *value) { if (lv_obj_get_style_text_font(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_font(obj, value, 0); } inline void set_text_align(lv_obj_t *obj, lv_text_align_t value) { if (lv_obj_get_style_text_align(obj, LV_PART_MAIN) != value) lv_obj_set_style_text_align(obj, value, 0); } inline void pad_vertical(lv_obj_t *obj, int value) { if (lv_obj_get_style_pad_top(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_top(obj, value, 0); if (lv_obj_get_style_pad_bottom(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_bottom(obj, value, 0); } inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line_width(obj, LV_PART_MAIN) != value) lv_obj_set_style_line_width(obj, value, 0); } // Custom cards draw into a transparent `extra` container; parts are rebuilt only // when a slot changes mode, so paging keeps RAM use flat on the CYD. // A slot whose next card draws no custom part only hides them: paging back to the clock or // graph in that slot reuses its objects instead of creating them again (tens of ms per card). inline void hide_extra(Widgets &w) { if(!w.extra)return; if(!lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.extra,LV_OBJ_FLAG_HIDDEN); w.fill_points=nullptr;w.fill_count=0; } inline void end_extra(Widgets &w) { if(!w.extra)return; hide_extra(w); if(!w.extra_mode.empty()){lv_obj_clean(w.extra);w.parts.fill(nullptr);w.extra_mode.clear();delete[] w.points;w.points=nullptr;} } // Two triangles per segment between the polyline and its baseline. No canvas // buffer is needed, so the CYD can afford it as well. inline void extra_draw(lv_event_t *e) { auto &w=*static_cast(lv_event_get_user_data(e)); if(!w.fill_points || w.fill_count<2 || !w.fill_opa)return; auto *layer=lv_event_get_layer(e);lv_area_t area;lv_obj_get_coords(w.extra,&area); lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=w.fill_color;dsc.opa=w.fill_opa; const lv_value_precise_t ox=area.x1+w.fill_x, oy=area.y1+w.fill_y, base=oy+w.fill_base; for(unsigned i=0;i+1