d6e5b35dcf
A tap on a cover now opens a card like Home Assistant's own, in the style of the climate and vacuum cards: a tall position slider on which the blind hangs from the top, a tilt slider over slats for venetian blinds, and open, stop and close keys. The key of the direction the cover moves is filled; a key that cannot move it further is disabled and stays out of the keys the card's tick enables. Sliders show their value while dragged and send it on release. The card offers only what supported_features allows. A battery-powered blind shows its battery at the top right: the app sends the device's battery sensor as the extra the vacuum card uses, and adds current_tilt_position to the attributes. Checking Studio 1 after its update to firmware 0.2.49 found that a setting number equal to its initial state 0 (night brightness 0 %) was never published, so Home Assistant kept it unknown and ESP Screens could not change it; apply_screen_settings now publishes a number without a state. The app also took "unavailable" for the device name during the restart and wrote sensor.esp_screens_unavailable; diagnostics ignore unknown and unavailable. The editor redraws its tiles, top bar and entity list when the live stream opened a screen before the first full inventory (a busy add-on), and loads app.js and style.css by content stamp after Safari kept an old script. Checked with 254 Python and 14 C++ tests, generate_packages --check, ESP32 builds of both profiles, and 20 API checks of the card on host builds of both boards. Firmware 0.2.50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2544 lines
165 KiB
C++
2544 lines
165 KiB
C++
#pragma once
|
|
#include "runtime_model.h"
|
|
#include "header_bar.h"
|
|
#include "tile_palette.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 "tile_controls.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 <functional>
|
|
#include <algorithm>
|
|
|
|
namespace runtime_tiles {
|
|
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;
|
|
inline esphome::ESPPreferenceObject rotation_preference;
|
|
inline esphome::ESPPreferenceObject swipe_preference;
|
|
inline esphome::ESPPreferenceObject home_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;
|
|
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;
|
|
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<esphome::ESPTime()> 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<bool()> 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<uint32_t>(t.timestamp) : 0; }
|
|
inline void tick();
|
|
inline void refresh_tile(size_t index);
|
|
#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 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;
|
|
inline std::function<void()> 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<screen_settings::Settings>(0x53435231);
|
|
swipe_preference = esphome::global_preferences->make_preference<uint32_t>(0x53575031);
|
|
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
|
|
uint32_t swipe_saved=0;
|
|
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
|
|
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<uint32_t>(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<int32_t>(auto_home_seconds, 30, 3600)};
|
|
home_preference.save(&home);
|
|
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<bool>()) return false;
|
|
*flag_values[i] = obj[flags[i]].as<bool>();
|
|
}
|
|
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<int>() || obj[numbers[i]].is<bool>()) return false;
|
|
*values[i] = obj[numbers[i]].as<int>();
|
|
}
|
|
return s.valid();
|
|
}
|
|
inline std::function<void(Tile &)> detail, detail_update;
|
|
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`.
|
|
bool wide=false; int base_width=0; lv_obj_t *extra{}; std::string extra_mode; std::array<lv_obj_t *, 20> 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;
|
|
// 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; int panel_w=0;
|
|
std::array<lv_obj_t *,3> keys{}, key_icons{}; std::array<int,3> key_commands{}; std::array<std::string,3> key_args; std::array<int,3> 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{};
|
|
// 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, 10> 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;
|
|
}
|
|
// 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<float>() && !value.is<int>()) return fallback;
|
|
float n = value.as<float>();
|
|
return std::isfinite(n) ? n : fallback;
|
|
}
|
|
inline std::string string(JsonVariant value, size_t maximum = 160) {
|
|
if (!value.is<const char *>()) return {};
|
|
std::string s = value.as<std::string>();
|
|
if (s.size() > maximum) {
|
|
while (maximum > 0 && (static_cast<unsigned char>(s[maximum]) & 0xC0) == 0x80) --maximum;
|
|
s.resize(maximum);
|
|
}
|
|
return s;
|
|
}
|
|
inline std::string list(JsonVariant value) {
|
|
if (!value.is<JsonArray>()) 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<int>() != 1) { result = "Error: protocol version"; return false; }
|
|
auto op = string(root["op"]);
|
|
if (op == "layout") {
|
|
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
|
|
auto settings = screen_settings::current;
|
|
if (!root["settings"].isNull() && (!root["settings"].is<JsonObject>() ||
|
|
!parse_settings(root["settings"].as<JsonObject>(), settings))) {
|
|
result = "Error: screen settings"; return false;
|
|
}
|
|
std::vector<std::string> entities;
|
|
for (JsonVariant entity : root["entities"].as<JsonArray>()) {
|
|
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
|
|
entities.push_back(entity.as<std::string>());
|
|
}
|
|
if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false;
|
|
if(!root["auto_home"].isNull() && !root["auto_home"].is<bool>())return false;
|
|
if(!root["auto_home_seconds"].isNull() && (!root["auto_home_seconds"].is<unsigned>() ||
|
|
root["auto_home_seconds"].as<unsigned>()<30 || root["auto_home_seconds"].as<unsigned>()>3600))return false;
|
|
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
|
|
root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false;
|
|
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
|
|
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
|
|
// Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers.
|
|
std::vector<uint8_t> positions;
|
|
if (!root["slots"].isNull()) {
|
|
if (!root["slots"].is<JsonArray>()) return false;
|
|
for (JsonVariant slot : root["slots"].as<JsonArray>()) {
|
|
if (!slot.is<unsigned>() || slot.as<unsigned>() >= MAX_SLOTS || positions.size() == MAX_TILES) return false;
|
|
positions.push_back(static_cast<uint8_t>(slot.as<unsigned>()));
|
|
}
|
|
}
|
|
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)) return false;
|
|
// Empty pages the user keeps on purpose; absent on older managers.
|
|
model.pages = root["pages"].is<unsigned>() ? static_cast<uint8_t>(std::clamp<unsigned>(root["pages"].as<unsigned>(), 1, MAX_PAGES)) : 1;
|
|
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
|
|
swipe_pages=root["swipe_pages"].as<bool>();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved);
|
|
}
|
|
if((root["auto_home"].is<bool>() && auto_home!=root["auto_home"].as<bool>()) ||
|
|
(root["auto_home_seconds"].is<unsigned>() && auto_home_seconds!=(int32_t)root["auto_home_seconds"].as<unsigned>())){
|
|
if(root["auto_home"].is<bool>())auto_home=root["auto_home"].as<bool>();
|
|
if(root["auto_home_seconds"].is<unsigned>())auto_home_seconds=(int32_t)root["auto_home_seconds"].as<unsigned>();
|
|
HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home);
|
|
}
|
|
bool rotation_changed=false;
|
|
if(rotation_supported && root["rotation"].is<unsigned>() && rotation!=root["rotation"].as<unsigned>()){
|
|
rotation=root["rotation"].as<unsigned>();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<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
|
|
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 || 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<const char *>()) return false;
|
|
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
|
|
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
|
|
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
|
|
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<JsonArray>()) return false;
|
|
header_bar::Bar next;
|
|
for (JsonVariant value : root["items"].as<JsonArray>()) {
|
|
if (next.count == header_bar::MAX_ITEMS || !value.is<JsonObject>()) 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<unsigned>() ? value["e"].as<uint32_t>() : 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;
|
|
}
|
|
#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<unsigned>() || !root["a"].is<JsonObject>()) return false;
|
|
unsigned index = root["i"].as<unsigned>();
|
|
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<JsonObject>();
|
|
// 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;
|
|
tile.wide = string(options["size"]) == "wide";
|
|
bool repack = was_wide != tile.wide;
|
|
// 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;
|
|
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
|
|
if (next.forecast.size() == 5) break;
|
|
next.forecast.emplace_back(); auto &f = next.forecast.back();
|
|
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<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
|
|
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<unsigned>() ? extra["last"].as<uint32_t>() : 0;
|
|
next.sunrise = string(extra["rise"], 5); next.sunset = string(extra["set"], 5);
|
|
next.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
|
|
next.duration = string(extra["dur"], 16); next.remaining = string(extra["rem"], 16);
|
|
tile.has_history=false;
|
|
if (root["history"]["values"].is<JsonArray>()) {
|
|
tile.history.assign(24,NAN);unsigned j=0;
|
|
for (JsonVariant value:root["history"]["values"].as<JsonArray>()) {
|
|
if(j==24) break; tile.history[j++]=number(value); }
|
|
tile.has_history=j>0;tile.history_hours=std::clamp(root["history"]["hours"].as<unsigned>(),1u,24u);
|
|
} else if (!tile.history.empty()) { tile.history.clear(); tile.history.shrink_to_fit(); }
|
|
if (a["options"].is<JsonArray>()) for(JsonVariant option:a["options"].as<JsonArray>()) {
|
|
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<bool>() && a["is_volume_muted"].as<bool>();
|
|
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<uint32_t>();
|
|
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.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<JsonArray>()) for (JsonVariant speed : a["fan_speed_list"].as<JsonArray>()) {
|
|
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<std::pair<char, std::string>> tapped;
|
|
if (tile.loading(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<JsonArray>()) 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<JsonArray>()) { if (row.values.size() == 6) break; row.values.push_back(string(value, 48)); }
|
|
if (c["l"].is<JsonArray>()) for (JsonVariant label : c["l"].as<JsonArray>()) {
|
|
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;
|
|
if (!std::isfinite(tile.battery)) tile.battery = number(extra["bat"]);
|
|
next.charging = extra["chg"].is<int>() && extra["chg"].as<int>() == 1;
|
|
next.room = string(extra["room"], 32);
|
|
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<int>(index) && detail_update) detail_update(tile);
|
|
refresh_detail(index);
|
|
result = model.ready() ? "Synced" : "Loading tiles";
|
|
return true;
|
|
});
|
|
return result;
|
|
}
|
|
inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="") {
|
|
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);}
|
|
for(size_t i=0;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());refresh_tile(i);}
|
|
esphome::api::global_api_server->send_homeassistant_action(request);
|
|
ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),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,lv_color_hex(0x202020),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"?"Home":t.state=="not_home"?"Away":t.state;
|
|
if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the 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"?"Running...":last_run_text(t.last_run);
|
|
if(t.state=="on")return "On";
|
|
if(t.state=="off")return "Off";
|
|
if(t.state=="docked")return "Docked";
|
|
if(t.state=="cleaning")return "Cleaning";
|
|
if(t.state=="paused")return "Paused";
|
|
if(t.state=="returning")return "Returning to dock";
|
|
if(t.state=="idle")return "Idle";
|
|
if(t.state=="error")return "Check the robot in HA";
|
|
if(!t.available())return "Unavailable";
|
|
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;
|
|
if(d=="cover")value=std::isfinite(t.position)?t.position/100:0;
|
|
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.loading(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.
|
|
if(d=="light")action("light.turn_on",t.entity,"brightness",std::to_string(std::max(3,(int)std::lround(value*255))));
|
|
if(d=="fan")action("fan.set_percentage",t.entity,"percentage",std::to_string((int)std::lround(value*100)));
|
|
if(d=="cover")action("cover.set_cover_position",t.entity,"position",std::to_string((int)std::lround(value*100)));
|
|
if(d=="media_player")action("media_player.volume_set",t.entity,"volume_level",std::to_string(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)); }
|
|
}
|
|
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;}
|
|
// 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);
|
|
for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider){index=w.index;break;}
|
|
bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
|
|
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);
|
|
}
|
|
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,lv_color_hex(command==0?0x009FE3:0xD9E6F0),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,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_event_cb(button,[](lv_event_t *e){
|
|
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
|
|
if(!fresh()||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.loading(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<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
|
|
for(const auto &[first,kind]:rows)
|
|
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.size())choose(t,*row,row->values[cmd-first]);}
|
|
if(cmd==20)action("media_player.media_play_pause",t.entity);
|
|
if(cmd==21)action("media_player.media_previous_track",t.entity);
|
|
if(cmd==22)action("media_player.media_next_track",t.entity);
|
|
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);
|
|
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
|
|
lv_obj_set_style_bg_color(button,lv_color_hex(0x0075B0),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 ----
|
|
inline uint32_t weather_accent(const std::string &c) {
|
|
if(c=="sunny")return 0xFFB300;
|
|
if(c=="clear-night")return 0x6E41AB;
|
|
if(c=="rainy"||c=="pouring"||c=="lightning-rainy"||c=="snowy-rainy")return 0x1E88E5;
|
|
if(c=="snowy"||c=="hail")return 0x4FC3F7;
|
|
if(c=="lightning")return 0xFFA000;
|
|
if(c=="partlycloudy")return 0x7E9BB5;
|
|
return 0x78909C;
|
|
}
|
|
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;
|
|
}
|
|
// "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%% · %.1f mm",(int)std::lround(chance),mm);return b;}
|
|
snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b;
|
|
}
|
|
if(std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),mm<10?"%.1f mm":"%.0f mm",mm);return b;}
|
|
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,lv_color_hex(0xFFFFFF),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,lv_color_hex(0xDDDDDD),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;
|
|
uint32_t ink=0x202020,muted=0x6B6B6B,rain=0x1E88E5;
|
|
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<unsigned>(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;
|
|
int y=large?62: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):"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)){snprintf(b,sizeof(b),"Feels like %.0f°",weather.feels);details=b;}
|
|
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<columns;++i){
|
|
const auto &h=weather.hours[i];int x=card_pad+i*col;
|
|
detail_text(now,h.time,x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted);
|
|
detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition));
|
|
detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink);
|
|
detail_text(now,rain_text(h.rain,h.mm,false),x,hy+small_h+mini_h+text_h+(large?8:3),col,small,LV_TEXT_ALIGN_CENTER,rain);
|
|
}
|
|
}
|
|
y+=card_a_h+(large?12:6);
|
|
// Coming days: a heading and a card with one row per day.
|
|
if(!weather.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
|
|
if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
|
|
int card_b_h=height-y-(large?10:4);
|
|
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
|
|
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)weather.forecast.size();
|
|
int day_w=large?46:26,icon_x=card_pad+day_w,cond_x=icon_x+mini_h+(large?12:5);
|
|
int high_w=large?52:30,low_w=large?46:28,rain_w=large?120:60,drop_w=tiny_h+(large?4:2);
|
|
int temps_x=width-2*pad-card_pad-high_w-low_w,rain_x=temps_x-(large?14:6)-rain_w;
|
|
for(unsigned i=0;i<weather.forecast.size();++i){
|
|
const auto &f=weather.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
|
|
detail_text(days,f.day,card_pad,tcy,day_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
|
|
detail_text(days,weather_icon(f.condition),icon_x,ry+(row-mini_h)/2,mini_h+6,mini,LV_TEXT_ALIGN_LEFT,weather_accent(f.condition));
|
|
detail_text(days,weather_text(f.condition),cond_x,scy,std::max(1,rain_x-cond_x-4),small,LV_TEXT_ALIGN_LEFT,muted);
|
|
std::string wet=rain_text(f.rain,f.mm,large);
|
|
if(!wet.empty()){
|
|
detail_text(days,"\U000F058E",rain_x,ry+(row-tiny_h)/2,drop_w,tiny,LV_TEXT_ALIGN_LEFT,rain);
|
|
detail_text(days,wet,rain_x+drop_w,scy,rain_w-drop_w,small,LV_TEXT_ALIGN_LEFT,rain);
|
|
}
|
|
detail_text(days,std::isfinite(f.high)?degrees(f.high):"",temps_x,tcy,high_w,detail_font,LV_TEXT_ALIGN_RIGHT,ink);
|
|
detail_text(days,std::isfinite(f.low)?degrees(f.low):"",temps_x+high_w,scy,low_w,small,LV_TEXT_ALIGN_RIGHT,muted);
|
|
}
|
|
}
|
|
// ---- Vacuum card ----
|
|
// A robot with its state and battery, the two commands used most, and one block that says how it cleans:
|
|
// the cleaning mode, then suction and water. Only the rows the chosen mode uses are shown, and they sit
|
|
// below the mode, so a tap never moves the control under the finger. Native shapes and labels only.
|
|
// Native shapes keep the robot crisp without image buffers or extra layers.
|
|
inline lv_obj_t *detail_shape(lv_obj_t *parent,int x,int y,int w,int h,uint32_t color,int radius){
|
|
auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_set_pos(o,x,y);lv_obj_set_size(o,w,h);
|
|
lv_obj_set_style_radius(o,radius,0);lv_obj_set_style_bg_color(o,lv_color_hex(color),0);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);
|
|
lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);return o;
|
|
}
|
|
inline int text_width(const std::string &text,const lv_font_t *font){
|
|
lv_point_t size;lv_text_get_size(&size,text.c_str(),font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_NONE);return size.x;
|
|
}
|
|
// The state colour, as Home Assistant colours a vacuum: teal while cleaning, blue on the way back, amber
|
|
// when paused, red on an error; a docked or idle robot keeps the card's own blue. `tint` is the halo.
|
|
struct VacuumLook { uint32_t accent, tint; };
|
|
inline VacuumLook vacuum_look(const std::string &state){
|
|
if(state=="cleaning")return {0x009688,0xDCF0EE};
|
|
if(state=="returning")return {0x2196F3,0xE1EFFD};
|
|
if(state=="paused")return {0xFF9800,0xFFF0DA};
|
|
if(state=="error")return {0xF44336,0xFDE4E2};
|
|
return {0x00A6ED,0xEAF5FC};
|
|
}
|
|
// A robot seen from above on a halo: a white body with a rim, the laser turret, a light bar in the state colour.
|
|
inline void vacuum_robot(lv_obj_t *parent,int x,int y,int size,const VacuumLook &look){
|
|
auto *halo=detail_shape(parent,x,y,size,size,look.tint,size/2);
|
|
int r=size*72/100,rim=std::max(1,size/44);
|
|
auto *body=detail_shape(halo,(size-r)/2,(size-r)/2,r,r,0xFFFFFF,r/2);
|
|
lv_obj_set_style_border_width(body,rim,0);lv_obj_set_style_border_color(body,lv_color_hex(0xCEDDE6),0);
|
|
int c=r-2*rim;auto s=[&](int v){return std::max(1,v*c/100);};
|
|
detail_shape(body,s(32),s(10),s(36),s(36),0xE3EBEF,s(18));
|
|
detail_shape(body,s(41),s(19),s(18),s(18),0xA8BCC8,s(9));
|
|
detail_shape(body,s(31),s(70),s(38),std::max(3,s(7)),look.accent,s(4));
|
|
}
|
|
// A battery like a phone's: outline, level fill (red below 20 %) and a small cap.
|
|
inline void vacuum_battery(lv_obj_t *parent,int x,int y,int w,int h,float level){
|
|
int line=std::max(1,h/7),fill_w=w-4*line,fill=std::clamp((int)std::lround(fill_w*level/100.0f),0,fill_w);
|
|
auto *shell=detail_shape(parent,x,y,w,h,0xFFFFFF,std::max(2,h/4));
|
|
lv_obj_set_style_bg_opa(shell,LV_OPA_TRANSP,0);
|
|
lv_obj_set_style_border_width(shell,line,0);lv_obj_set_style_border_color(shell,lv_color_hex(0x7D858D),0);
|
|
if(fill)detail_shape(shell,line,line,fill,h-4*line,level<20?0xE53935:0x46525E,std::max(1,h/9));
|
|
detail_shape(parent,x+w,y+h*3/10,std::max(2,line+1),h-2*(h*3/10),0x7D858D,1);
|
|
}
|
|
// Battery meter, level and a green bolt while charging, in one row from `x`; returns the row's width.
|
|
// Without a parent it only measures.
|
|
inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font_t *font,bool large){
|
|
const lv_font_t *bolt_font=large && watch_icon_font?watch_icon_font:mini_icon_font;
|
|
std::string percent=std::to_string((int)std::lround(t.battery))+"%";
|
|
int h=lv_font_get_line_height(font),meter_w=large?30:22,meter_h=large?15:11,gap=large?10:6,words=text_width(percent,font);
|
|
int bolt_w=t.extra().charging && bolt_font?text_width("\U000F0241",bolt_font):0;
|
|
int width=meter_w+3+gap+words+(bolt_w?gap/2+bolt_w:0);
|
|
if(!parent)return width;
|
|
vacuum_battery(parent,x,y+(h-meter_h)/2,meter_w,meter_h,t.battery);
|
|
int px=x+meter_w+3+gap;
|
|
detail_text(parent,percent,px,y,words+2,font,LV_TEXT_ALIGN_LEFT,0x5F6368);
|
|
if(bolt_w)detail_text(parent,"\U000F0241",px+words+gap/2,y+(h-lv_font_get_line_height(bolt_font))/2,bolt_w+2,bolt_font,LV_TEXT_ALIGN_LEFT,0x43A047);
|
|
return width;
|
|
}
|
|
// Put a detail_button's words in `font`, sized to the words and centred (or at `x` when given).
|
|
inline lv_obj_t *button_words(lv_obj_t *button,const lv_font_t *font,uint32_t color,int available,int x=-1){
|
|
auto *label=lv_obj_get_child(button,0);
|
|
lv_obj_set_style_text_font(label,font,0);lv_obj_set_style_text_color(label,lv_color_hex(color),0);
|
|
lv_obj_set_size(label,std::min(available,text_width(lv_label_get_text(label),font)+2),lv_font_get_line_height(font));
|
|
if(x<0)lv_obj_center(label);else lv_obj_align(label,LV_ALIGN_LEFT_MID,x,0);
|
|
return label;
|
|
}
|
|
// A command button with its icon before the words, the two centred as one group.
|
|
inline lv_obj_t *vacuum_command(const char *icon,const char *text,int x,int y,int w,int h,int command,bool primary,
|
|
const lv_font_t *font,const lv_font_t *icon_font,int radius){
|
|
auto *button=detail_button(text,x,y,w,h,command);
|
|
uint32_t ink=primary?0xFFFFFF:0x1B1B1B;
|
|
lv_obj_set_style_radius(button,radius,0);
|
|
lv_obj_set_style_bg_color(button,lv_color_hex(primary?0x009FE3:0xFFFFFF),0);
|
|
if(!primary){
|
|
lv_obj_set_style_border_width(button,1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
|
|
lv_obj_set_style_bg_color(button,lv_color_hex(0xEEEEEE),LV_STATE_PRESSED);
|
|
}
|
|
int gap=std::max(6,h/7),icon_w=text_width(icon,icon_font),words=text_width(text,font);
|
|
int group=icon_w+gap+words,left=std::max(4,(w-group)/2);
|
|
auto *glyph=lv_label_create(button);lv_label_set_text(glyph,icon);lv_obj_remove_flag(glyph,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_text_font(glyph,icon_font,0);lv_obj_set_style_text_color(glyph,lv_color_hex(ink),0);
|
|
lv_obj_align(glyph,LV_ALIGN_LEFT_MID,left,0);
|
|
button_words(button,font,ink,w-left-icon_w-gap-4,left+icon_w+gap);
|
|
return button;
|
|
}
|
|
// A segmented control: one rounded track, each option as wide as its words plus an equal share of the
|
|
// room left, the chosen one filled in blue. Commands first..first+5.
|
|
inline void vacuum_segments(const Tile &t,const Choice &row,int first,int x,int y,int w,int h,uint32_t track,bool border,const lv_font_t *font){
|
|
int n=std::min<int>((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,lv_color_hex(0xDDDDDD),0);}
|
|
int inset=std::max(3,h/12),room=w-2*inset,words=0;int widths[6];
|
|
for(int i=0;i<n;++i){widths[i]=text_width(row.labels[i],font);words+=widths[i];}
|
|
int share=(room-words)/n;
|
|
const std::string &chosen=tile_controls::shown_value(t,row,esphome::millis());
|
|
int sx=x+inset;
|
|
for(int i=0;i<n;++i){
|
|
int sw=i==n-1?x+w-inset-sx:(words<=room?widths[i]+share:room/n);
|
|
bool selected=row.values[i]==chosen;
|
|
auto *segment=detail_button(row.labels[i].c_str(),sx,y+inset,sw,h-2*inset,first+i);
|
|
lv_obj_set_style_radius(segment,(h-2*inset)/2,0);
|
|
lv_obj_set_style_bg_color(segment,lv_color_hex(0x009FE3),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,lv_color_hex(0xD5EEFC),LV_STATE_PRESSED);
|
|
button_words(segment,font,selected?0xFFFFFF:0x1B1B1B,sw-6);
|
|
sx+=sw;
|
|
}
|
|
}
|
|
inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad){
|
|
uint32_t now=esphome::millis();
|
|
auto rows=tile_controls::vacuum_rows(t,now);
|
|
const Choice *mode=t.choice('m'),*suction=rows.suction?t.choice('s'):nullptr,*water=rows.water?t.choice('w'):nullptr;
|
|
bool automatic=mode && tile_controls::vacuum_role(t,now)=='a';
|
|
const lv_font_t *text=large?detail_font:(control_font?control_font:detail_font);
|
|
const lv_font_t *big=watch_font?watch_font:detail_font;
|
|
const lv_font_t *icons=mini_icon_font?mini_icon_font:detail_font;
|
|
VacuumLook look=vacuum_look(t.state);
|
|
bool cleaning=t.state=="cleaning",paused=t.state=="paused";
|
|
bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning";
|
|
int inner=width-2*pad,gap=large?12:6,radius=lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN);
|
|
std::string state=t.awaiting_action(now)&&!t.confirmed?"Command sent...":detail_state(t);
|
|
// A robot with little to set gets a hero on the small screen too; one with mode and water rows uses a status row.
|
|
bool small_hero=!large && !mode && !t.choice('w');
|
|
int y=large?92:52;
|
|
if(large || small_hero){
|
|
int hero_h=large?108:60,robot=large?84:48,edge=large?12:6;
|
|
auto *hero=detail_card(pad,y,inner,hero_h);
|
|
vacuum_robot(hero,edge,(hero_h-robot)/2,robot,look);
|
|
// Locate, when the robot can do it (supported_features 512; unknown features keep the button).
|
|
bool locate=large && (!t.supported || (t.supported & 512));
|
|
int key=48,text_x=edge+robot+(large?18:12),text_w=inner-text_x-(locate?key+2*edge:edge);
|
|
int line=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text),space=large?6:3;
|
|
bool room=on_the_way && !t.extra().room.empty();
|
|
int block=line+(room?space+text_h:0)+(battery?space+text_h:0),ty=(hero_h-block)/2;
|
|
detail_badge_status=detail_text(hero,state,text_x,ty,text_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
|
ty+=line;
|
|
if(room){ty+=space;detail_text(hero,t.extra().room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
|
|
if(battery){ty+=space;vacuum_power(hero,t,text_x,ty,text,large);}
|
|
if(locate){
|
|
auto *find=detail_button("",pad+inner-edge-key-6,y+(hero_h-key)/2,key,key,3);
|
|
lv_obj_set_style_radius(find,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(find,lv_color_hex(0xF1F1F1),0);
|
|
lv_obj_set_style_bg_color(find,lv_color_hex(0xDDDDDD),LV_STATE_PRESSED);
|
|
auto *glyph=lv_obj_get_child(find,0);lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,"\U000F034E");
|
|
lv_obj_set_style_text_color(glyph,lv_color_hex(0x46525E),0);lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
|
|
}
|
|
y+=hero_h+gap;
|
|
}else{
|
|
// Status row: a dot in the state colour, the state (and the room on the way), the battery at the right.
|
|
int line=lv_font_get_line_height(text),dot=8,power_w=battery?vacuum_power(nullptr,t,0,0,text,false):0;
|
|
detail_shape(detail_root,pad+2,y+(line-dot)/2,dot,dot,look.accent,dot/2);
|
|
int words=inner-dot-10-power_w-12,state_w=std::min(words,text_width(state,text)+2);
|
|
detail_badge_status=detail_text(detail_root,state,pad+dot+10,y,state_w,text,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
|
if(on_the_way && !t.extra().room.empty() && words-state_w>24)
|
|
detail_text(detail_root," · "+t.extra().room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
|
|
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",cleaning?(large?"Pause cleaning":"Pause"):paused?"Resume":(large?"Start cleaning":"Clean"),
|
|
pad,y,start_w,action_h,cleaning?1:0,true,text,icons,large?radius:action_h/2);
|
|
vacuum_command("\U000F05F8","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,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);(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);
|
|
uint32_t track=large?0xF1F1F1:0xFFFFFF;
|
|
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,0x6B6B6B);
|
|
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,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
|
}
|
|
}
|
|
// ---- 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.
|
|
inline constexpr uint32_t COVER_ACCENT = 0x926BC7, COVER_TRACK = 0xEFE8F7, COVER_SLATS = 0xDDD0EF;
|
|
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):"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<int>(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<count;++i){
|
|
int thick=std::max(2,pitch*(20+60*i/(count-1))/100);
|
|
detail_shape(slats,inset,radius/2+i*pitch+(pitch-thick)/2,w-2*inset,thick,COVER_SLATS,thick/2);
|
|
}
|
|
}
|
|
auto *slider=lv_slider_create(parent);lv_obj_remove_style_all(slider);
|
|
lv_obj_set_pos(slider,x,y);lv_obj_set_size(slider,w,h);lv_slider_set_orientation(slider,LV_SLIDER_ORIENTATION_VERTICAL);
|
|
lv_obj_set_style_radius(slider,radius,LV_PART_MAIN);lv_obj_set_style_radius(slider,radius,LV_PART_INDICATOR);
|
|
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_TRACK),LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_MAIN);
|
|
lv_obj_set_style_bg_color(slider,lv_color_hex(COVER_ACCENT),LV_PART_INDICATOR);lv_obj_set_style_bg_opa(slider,tilt?LV_OPA_TRANSP:LV_OPA_COVER,LV_PART_INDICATOR);
|
|
lv_obj_set_style_bg_color(slider,lv_color_hex(tilt?COVER_ACCENT:0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
|
|
lv_obj_set_style_radius(slider,LV_RADIUS_CIRCLE,LV_PART_KNOB);
|
|
lv_obj_set_style_opa(slider,LV_OPA_50,LV_STATE_DISABLED);
|
|
// LVGL centres the knob on the end of the fill in a square as wide as the slider; the pads shrink it to a
|
|
// handle bar. The position's handle sits inside the blind, an inset above its edge; the tilt's is centred.
|
|
int side=-(w-handle_w)/2;
|
|
lv_obj_set_style_pad_left(slider,side,LV_PART_KNOB);lv_obj_set_style_pad_right(slider,side,LV_PART_KNOB);
|
|
if(tilt){
|
|
int squeeze=-(w-2*handle_h)/2;
|
|
lv_obj_set_style_pad_top(slider,squeeze,LV_PART_KNOB);lv_obj_set_style_pad_bottom(slider,squeeze,LV_PART_KNOB);
|
|
// A margin past both ends keeps the handle on the track at 0 and 100 %.
|
|
int margin=1000*(inset+handle_h)/std::max(1,h-2*(inset+handle_h));
|
|
lv_slider_set_range(slider,-margin,1000+margin);
|
|
lv_slider_set_value(slider,std::isfinite(value)?(int)std::lround(std::clamp(value,0.0f,100.0f)*10):500,LV_ANIM_OFF);
|
|
}else{
|
|
lv_obj_set_style_pad_top(slider,inset+handle_h-w/2,LV_PART_KNOB);lv_obj_set_style_pad_bottom(slider,1-inset-w/2,LV_PART_KNOB);
|
|
// Reversed, the fill hangs from the top; the stub past 0 holds the handle of a cover that is fully open.
|
|
int stub=inset+handle_h+inset,below=1000*stub/std::max(1,h-stub);
|
|
lv_slider_set_range(slider,1000,-below);
|
|
lv_slider_set_value(slider,std::isfinite(value)?(int)std::lround((100.0f-std::clamp(value,0.0f,100.0f))*10):0,LV_ANIM_OFF);
|
|
}
|
|
lv_obj_add_event_cb(slider,cover_slider_event,LV_EVENT_VALUE_CHANGED,(void*)(uintptr_t)(tilt?1:0));
|
|
lv_obj_add_event_cb(slider,cover_slider_event,LV_EVENT_RELEASED,(void*)(uintptr_t)(tilt?1:0));
|
|
if(detail_action_count<32)detail_actions[detail_action_count++]=slider;
|
|
return slider;
|
|
}
|
|
// A row of pill keys with an icon each, as the climate card's modes. A key that cannot move the cover further
|
|
// is drawn disabled and left out of the keys the card enables again once Home Assistant answers.
|
|
inline void cover_key_row(const std::array<tile_controls::Key,3> &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<count;++i){
|
|
const auto &key=keys[i];
|
|
auto *button=detail_button("",x+(int)i*(key_w+gap),y,key_w,h,70+key.command);
|
|
lv_obj_set_style_radius(button,h/2,0);
|
|
lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?COVER_ACCENT:0xFFFFFF),0);
|
|
lv_obj_set_style_bg_color(button,lv_color_hex(key.checked?0x7552A8:0xEEEEEE),LV_STATE_PRESSED);
|
|
lv_obj_set_style_border_width(button,key.checked?0:1,0);lv_obj_set_style_border_color(button,lv_color_hex(0xDDDDDD),0);
|
|
auto *glyph=lv_obj_get_child(button,0);
|
|
lv_obj_set_style_text_font(glyph,icons,0);lv_label_set_text(glyph,key.icon);
|
|
lv_obj_set_style_text_color(glyph,lv_color_hex(key.checked?0xFFFFFF:0x1B1B1B),0);
|
|
lv_obj_set_size(glyph,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(glyph);
|
|
if(key.disabled){lv_obj_add_state(button,LV_STATE_DISABLED);if(detail_action_count && detail_actions[detail_action_count-1]==button)--detail_action_count;}
|
|
}
|
|
}
|
|
// A battery-powered cover shows its level at the top right, across from the back key: the meter over the
|
|
// percentage, red below 20 % (the battery sensor of its device, app 0.2.58+).
|
|
inline void cover_battery(const Tile &t,bool large,int width){
|
|
if(!std::isfinite(t.battery))return;
|
|
const lv_font_t *font=large?detail_font:(control_font?control_font:detail_font);
|
|
int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8,meter_w=large?30:22,meter_h=large?15:11,gap=large?4:2;
|
|
int level=(int)std::lround(std::clamp(t.battery,0.0f,100.0f));
|
|
int line=lv_font_get_line_height(font),block=meter_h+gap+line,cx=width-bar_x-bar/2,y=bar_y+(bar-block)/2;
|
|
vacuum_battery(detail_root,cx-meter_w/2-1,y,meter_w,meter_h,t.battery);
|
|
detail_text(detail_root,std::to_string(level)+"%",cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,0x5F6368);
|
|
}
|
|
inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad){
|
|
auto card=tile_controls::cover_card(t);
|
|
cover_battery(t,large,width);
|
|
const lv_font_t *text=large?detail_font:(control_font?control_font:detail_font);
|
|
const lv_font_t *big=watch_font?watch_font:detail_font;
|
|
cover_values[0]=cover_values[1]=nullptr;
|
|
int inner=width-2*pad,gap=large?12:6,key_h=large?64:38,bottom=height-(large?18:6);
|
|
// The tile icons' own size where it fits the keys (42 px on the Guition, 28 on the CYD).
|
|
const lv_font_t *key_icons=widgets[0].icon_font && lv_font_get_line_height(widgets[0].icon_font)<=key_h-6?widgets[0].icon_font:(mini_icon_font?mini_icon_font:detail_font);
|
|
std::array<tile_controls::Key,3> 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,"Position"};
|
|
if(card.tilt)parts[n++]={true,t.extra().tilt,"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<n;++i,x+=slider_w+column_gap){
|
|
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
|
int ty=top+edge+slider_h+2;
|
|
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x-30,ty,slider_w+60,big,LV_TEXT_ALIGN_CENTER,0x1B1B1B);
|
|
detail_text(detail_root,parts[i].caption,x-30,ty+value_h,slider_w+60,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
|
}
|
|
}else{
|
|
// The small screen puts the value and name beside each slider.
|
|
int edge=6,slider_h=box_h-2*edge,slider_w=n==2?48:56,label_w=n==2?76:96,column_gap=n==2?16:0;
|
|
int column=slider_w+8+label_w,x=pad+(inner-(n*column+(n-1)*column_gap))/2;
|
|
for(int i=0;i<n;++i,x+=column+column_gap){
|
|
cover_slider(detail_root,x,top+edge,slider_w,slider_h,parts[i].value,parts[i].tilt);
|
|
int ty=top+(box_h-value_h-caption_h)/2;
|
|
cover_values[parts[i].tilt?1:0]=detail_text(detail_root,percent(parts[i].value),x+slider_w+8,ty,label_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
|
detail_text(detail_root,parts[i].caption,x+slider_w+8,ty+value_h,label_w,text,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
|
|
}
|
|
}
|
|
}else{
|
|
// Open and close only (a garage door, a gate): the cover's icon on a halo and its state, as the vacuum's hero.
|
|
detail_card(pad,top,inner,box_h);
|
|
const lv_font_t *icon_font=widgets[0].icon_font?widgets[0].icon_font:mini_icon_font;
|
|
int halo=std::min(box_h-24,large?120:72);
|
|
auto *ring=detail_shape(detail_root,pad+(inner-halo)/2,top+(box_h-halo)/2,halo,halo,COVER_TRACK,halo/2);
|
|
if(icon_font){auto *icon=detail_text(ring,icon_for(t),0,(halo-lv_font_get_line_height(icon_font))/2,halo,icon_font,LV_TEXT_ALIGN_CENTER,COVER_ACCENT);(void)icon;}
|
|
}
|
|
int y=keys_y;
|
|
if(key_count){cover_key_row(keys,key_count,pad,y,inner,key_h,gap,key_icons);y+=key_h+gap;}
|
|
if(tilt_count)cover_key_row(tilt_keys,tilt_count,pad,y,inner,key_h,gap,key_icons);
|
|
}
|
|
inline void show_detail(unsigned index){
|
|
if(index>=model.count)return;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;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,lv_color_hex(0xE7E7E7),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,lv_color_hex(0xEEEEEE),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 card draws its own state (hero or status row) with the battery beside it.
|
|
if(d!="vacuum"){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,lv_color_hex(0x616161),0);}
|
|
if(t.is_switch()){
|
|
detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
|
|
detail_switch=lv_switch_create(detail_root);
|
|
lv_obj_set_size(detail_switch,large?240:140,large?112:64);
|
|
lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
|
|
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xB0B0B0),LV_PART_MAIN);
|
|
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFB900),LV_PART_INDICATOR|LV_STATE_CHECKED);
|
|
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFFFFF),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.awaiting_action(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)action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);
|
|
},LV_EVENT_VALUE_CHANGED,nullptr);
|
|
detail_actions[detail_action_count++]=detail_switch;
|
|
}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=="sensor"){
|
|
float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
|
|
if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
|
|
char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
|
|
int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
|
|
for(unsigned i=0;i<t.history.size() && i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
|
|
auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
|
|
detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
|
|
auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
|
|
}else if(d=="select"||d=="input_select"){
|
|
const auto &options=t.extra().options;
|
|
for(unsigned i=0;i<options.size();++i)detail_button(options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
|
|
}else if(d=="number"||d=="input_number"||d=="media_player"){
|
|
if(d=="media_player"){
|
|
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
|
|
int w=(width-pad*2-2*gap)/3;
|
|
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
|
|
}
|
|
detail_label(detail_root,d=="media_player"?"Volume":"Value",pad,top,width-2*pad);
|
|
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
|
|
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
|
|
}else if(d=="weather"){
|
|
render_weather_detail(t,large,width,height,pad);
|
|
}else if(d=="timer"){
|
|
detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad);
|
|
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
|
|
detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41);
|
|
}else if(d=="sun"){
|
|
detail_label(detail_root,"Sunrise "+t.extra().sunrise,pad,top,width-2*pad);
|
|
detail_label(detail_root,"Sunset "+t.extra().sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace runtime_tiles {
|
|
inline void refresh_detail(unsigned index){
|
|
if(!detail_root || lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) || detail_index!=index)return;
|
|
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
|
|
show_detail(index);
|
|
}
|
|
// Home Assistant weather conditions mapped to Material Design Icons glyphs.
|
|
inline const char *weather_icon(const std::string &condition) {
|
|
if (condition == "sunny") return "\U000F0599";
|
|
if (condition == "clear-night") return "\U000F0594";
|
|
if (condition == "cloudy") return "\U000F0590";
|
|
if (condition == "partlycloudy") return "\U000F0595";
|
|
if (condition == "rainy") return "\U000F0597";
|
|
if (condition == "pouring") return "\U000F0596";
|
|
if (condition == "snowy") return "\U000F0598";
|
|
if (condition == "snowy-rainy") return "\U000F067F";
|
|
if (condition == "fog") return "\U000F0591";
|
|
if (condition == "hail") return "\U000F0592";
|
|
if (condition == "lightning") return "\U000F0593";
|
|
if (condition == "lightning-rainy") return "\U000F067E";
|
|
if (condition == "windy" || condition == "windy-variant") return "\U000F059D";
|
|
if (condition == "exceptional") return "\U000F05D6";
|
|
return "\U000F0595";
|
|
}
|
|
inline const char *weather_text(const std::string &condition) {
|
|
if (condition == "sunny") return "Sunny";
|
|
if (condition == "clear-night") return "Clear, night";
|
|
if (condition == "cloudy") return "Cloudy";
|
|
if (condition == "partlycloudy") return "Partly cloudy";
|
|
if (condition == "rainy") return "Rainy";
|
|
if (condition == "pouring") return "Pouring";
|
|
if (condition == "snowy") return "Snowy";
|
|
if (condition == "snowy-rainy") return "Snowy, rainy";
|
|
if (condition == "fog") return "Fog";
|
|
if (condition == "hail") return "Hail";
|
|
if (condition == "lightning") return "Lightning";
|
|
if (condition == "lightning-rainy") return "Lightning, rainy";
|
|
if (condition == "windy" || condition == "windy-variant") return "Windy";
|
|
if (condition == "exceptional") return "Exceptional";
|
|
return condition.c_str();
|
|
}
|
|
inline const char *icon_for(const Tile &tile) {
|
|
if (!tile.icon.empty()) return tile.icon.c_str();
|
|
auto d = tile.domain();
|
|
if (d == "light") return "\U000F0335";
|
|
if (d == "climate") return "\U000F001B";
|
|
if (d == "vacuum") return "\U000F070D";
|
|
if (d == "fan") return "\U000F0210";
|
|
if (d == "cover") return "\U000F111C";
|
|
if (d == "scene" || d == "script") return "\U000F04B9";
|
|
if (d == "weather") return tile.available() ? weather_icon(tile.state) : "\U000F0595";
|
|
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
|
|
if (d == "sun") return tile.state == "above_horizon" ? "\U000F059B" : "\U000F059C";
|
|
if (d == "timer") return "\U000F051B";
|
|
if (d == "person") return "\U000F0004";
|
|
if (d == "screen") return tile.is_settings() ? "\U000F0493" : "\U000F0150";
|
|
return "\U000F0425";
|
|
}
|
|
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
|
|
inline uint32_t duration_seconds(const std::string &text) {
|
|
unsigned h = 0, m = 0, s = 0;
|
|
if (sscanf(text.c_str(), "%u:%u:%u", &h, &m, &s) == 3) return h * 3600 + m * 60 + s;
|
|
if (sscanf(text.c_str(), "%u:%u", &m, &s) == 2) return m * 60 + s;
|
|
return 0;
|
|
}
|
|
inline std::string countdown(uint32_t seconds) {
|
|
char b[16];
|
|
if (seconds >= 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"; 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 "Never run";
|
|
auto when = esphome::ESPTime::from_epoch_local(epoch);
|
|
auto now = now_time ? now_time() : esphome::ESPTime{};
|
|
if (!when.is_valid()) return "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 std::string("Last ") + clock;
|
|
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Yesterday ") + clock;
|
|
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
|
|
}
|
|
inline std::string timer_text(const Tile &t) {
|
|
const Extra &x = t.extra();
|
|
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(x.timer_end > now && now ? x.timer_end - now : 0); }
|
|
if (t.state == "paused") return "Paused " + countdown(duration_seconds(x.remaining));
|
|
return x.duration.empty() ? "Off" : countdown(duration_seconds(x.duration));
|
|
}
|
|
inline std::string weekday_text(const esphome::ESPTime &now) {
|
|
static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
|
|
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : "";
|
|
}
|
|
inline std::string month_short(const esphome::ESPTime &now) {
|
|
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
|
|
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : "";
|
|
}
|
|
inline std::string date_text(const esphome::ESPTime &now) {
|
|
static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
|
|
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
|
|
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
|
|
}
|
|
inline void event(lv_event_t *event) {
|
|
auto &w = *static_cast<Widgets *>(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;
|
|
// 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_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.loading(esphome::millis()) || tile.tap=="none") return;
|
|
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
|
|
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
|
|
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
|
|
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
|
|
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
|
|
// A short tap runs or pauses the timer; holding opens the card with a cancel button.
|
|
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
|
|
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="cover" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
|
|
if (open) {
|
|
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {
|
|
active_index = w.index;
|
|
tile.begin(esphome::millis(),true);
|
|
if (detail) detail(tile);
|
|
} else { active_index=w.index;show_detail(w.index); }
|
|
return;
|
|
}
|
|
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
|
|
if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity);
|
|
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
|
|
}
|
|
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
|
|
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
|
|
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0) {
|
|
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 = 0) {
|
|
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 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 lv_obj_get_height(w.tile) - lv_obj_get_style_space_top(w.tile, LV_PART_MAIN) - lv_obj_get_style_space_bottom(w.tile, LV_PART_MAIN);
|
|
}
|
|
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.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);
|
|
// 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_set_style_bg_color(w.slider,lv_color_hex(0xFFFFFF),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);
|
|
lv_obj_set_style_radius(w.slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
|
|
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); }
|
|
// HA's default domain/state palette. Pastel circles also identify inactive domains.
|
|
// Custom Lovelace card/theme CSS is not an entity attribute and is not imported.
|
|
inline uint32_t domain_accent(const Tile &t) {
|
|
auto d=t.domain();
|
|
if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return 0xFFC107;
|
|
// A climate card that is off or in an unknown mode keeps HA's orange climate circle.
|
|
if(d=="climate"){uint32_t c=tile_controls::mode_color(t.state);return c==0x9E9E9E?0xFF9800:c;}
|
|
if(d=="vacuum")return t.state=="error"?0xF44336:0x009688;
|
|
if(d=="fan")return 0x00BCD4;
|
|
if(d=="cover")return 0x926BC7;
|
|
if(d=="media_player")return 0x03A9F4;
|
|
if(d=="scene" || d=="script")return 0x926BC7;
|
|
if(d=="select" || d=="input_select")return 0x3F51B5;
|
|
if(d=="number" || d=="input_number")return 0x009688;
|
|
if(d=="weather")return t.state=="sunny"?0xFFC107:t.state=="clear-night"?0x6E41AB:0x03A9F4;
|
|
if(d=="sun")return t.state=="above_horizon"?0xFF9800:0x6E41AB;
|
|
if(d=="timer")return t.state=="active"?0x009688:t.state=="paused"?0xFF9800:0x9E9E9E;
|
|
if(d=="person")return t.state=="home"?0x4CAF50:0x9E9E9E;
|
|
if(d=="screen")return 0x2196F3;
|
|
if(d=="sensor"){
|
|
if(t.unit=="lx")return 0xFFC107;
|
|
if(t.unit=="°C" || t.unit=="°F")return 0xFF6F22;
|
|
if(t.unit=="kWh" || t.unit=="Wh")return 0x926BC7;
|
|
if(t.unit=="%")return 0x009688;
|
|
}
|
|
return 0x2196F3;
|
|
}
|
|
// 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<Widgets *>(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<w.fill_count;++i){
|
|
const auto &a=w.fill_points[i],&b=w.fill_points[i+1];
|
|
dsc.p[0]={ox+a.x,oy+a.y};dsc.p[1]={ox+b.x,oy+b.y};dsc.p[2]={ox+b.x,base};lv_draw_triangle(layer,&dsc);
|
|
dsc.p[1]=dsc.p[2];dsc.p[2]={ox+a.x,base};lv_draw_triangle(layer,&dsc);
|
|
}
|
|
}
|
|
inline void begin_extra(Widgets &w,const char *mode,int width,int height) {
|
|
if(!w.extra){
|
|
w.extra=lv_obj_create(w.tile);lv_obj_remove_style_all(w.extra);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_event_cb(w.extra,extra_draw,LV_EVENT_DRAW_MAIN,&w);
|
|
}
|
|
if(w.extra_mode!=mode){end_extra(w);w.extra_mode=mode;w.points=new lv_point_precise_t[POINT_BUFFER];w.cached_active=-1;}
|
|
w.fill_points=nullptr;w.fill_count=0;
|
|
// Parts that were hidden kept the colours of the card they last showed.
|
|
if(lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){w.cached_active=-1;lv_obj_remove_flag(w.extra,LV_OBJ_FLAG_HIDDEN);}
|
|
lv_obj_set_pos(w.extra,0,0);lv_obj_set_size(w.extra,width,height);
|
|
}
|
|
// Catmull-Rom curve through the samples: the trend reads smoothly without extra data.
|
|
inline unsigned smooth(const lv_point_precise_t *in,unsigned n,lv_point_precise_t *out,unsigned capacity,int width,int height) {
|
|
if(n<2 || capacity<2){for(unsigned i=0;i<n && i<capacity;++i)out[i]=in[i];return n<capacity?n:capacity;}
|
|
const unsigned steps=4;unsigned count=0;
|
|
for(unsigned i=0;i+1<n;++i){
|
|
const auto &p0=in[i?i-1:0],&p1=in[i],&p2=in[i+1],&p3=in[i+2<n?i+2:n-1];
|
|
for(unsigned s=0;s<steps && count<capacity-1;++s){
|
|
float t=float(s)/steps,t2=t*t,t3=t2*t;
|
|
float x=0.5f*(2*p1.x+(-p0.x+p2.x)*t+(2*p0.x-5*p1.x+4*p2.x-p3.x)*t2+(-p0.x+3*p1.x-3*p2.x+p3.x)*t3);
|
|
float y=0.5f*(2*p1.y+(-p0.y+p2.y)*t+(2*p0.y-5*p1.y+4*p2.y-p3.y)*t2+(-p0.y+3*p1.y-3*p2.y+p3.y)*t3);
|
|
out[count++]={(lv_value_precise_t)std::clamp(x,0.0f,float(width-1)),(lv_value_precise_t)std::clamp(y,0.0f,float(height-1))};
|
|
}
|
|
}
|
|
out[count++]=in[n-1];return count;
|
|
}
|
|
inline lv_obj_t *part_label(Widgets &w,unsigned i,const lv_font_t *font,int x,int y,int width,lv_text_align_t align,const std::string &text) {
|
|
auto *&p=w.parts[i];
|
|
if(!p){p=lv_label_create(w.extra);lv_label_set_long_mode(p,LV_LABEL_LONG_CLIP);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);}
|
|
set_font(p,font);set_text_align(p,align);
|
|
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,std::max(1,width),lv_font_get_line_height(font));label(p,text);return p;
|
|
}
|
|
inline lv_obj_t *part_dot(Widgets &w,unsigned i,int x,int y,int size) {
|
|
auto *&p=w.parts[i];
|
|
if(!p){p=lv_obj_create(w.extra);lv_obj_remove_style_all(p);lv_obj_set_style_bg_opa(p,LV_OPA_COVER,0);lv_obj_set_style_radius(p,LV_RADIUS_CIRCLE,0);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(p,LV_OBJ_FLAG_SCROLLABLE);}
|
|
lv_obj_set_pos(p,x,y);lv_obj_set_size(p,size,size);return p;
|
|
}
|
|
// Points are relative to (x,y): the line object then covers only its own rectangle.
|
|
inline lv_obj_t *part_line(Widgets &w,unsigned i,lv_point_precise_t *points,unsigned count,int width,int x=0,int y=0) {
|
|
auto *&p=w.parts[i];
|
|
if(!p){p=lv_line_create(w.extra);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_line_rounded(p,true,0);}
|
|
set_line_width(p,width);lv_line_set_points(p,points,count);lv_obj_set_pos(p,x,y);return p;
|
|
}
|
|
inline std::string time_text(esphome::ESPTime now) {
|
|
return now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : "--:--";
|
|
}
|
|
// Digital: big time over the date. Analog: index strokes (numerals at 12/3/6/9 on
|
|
// large cards) with hour and minute hands. A single card adds a calendar block
|
|
// beside the dial (weekday, big day number, short month); a wide card adds the
|
|
// digital time and the date instead. Parts: 0-11 marks, 12-13 hands, 14 centre,
|
|
// 15-17 text, 18 the second hand (points 28-29), shown while the screen is awake.
|
|
inline void second_hand(Widgets &w,const esphome::ESPTime &now) {
|
|
float a=(now.is_valid()?now.second:0)*3.14159265f/30;
|
|
w.points[28]={(lv_value_precise_t)(w.hand_cx-w.hand_r*0.2f*sinf(a)),(lv_value_precise_t)(w.hand_cy+w.hand_r*0.2f*cosf(a))};
|
|
w.points[29]={(lv_value_precise_t)(w.hand_cx+w.hand_r*0.92f*sinf(a)),(lv_value_precise_t)(w.hand_cy-w.hand_r*0.92f*cosf(a))};
|
|
auto *p=part_line(w,18,w.points+28,2,w.hand_width);
|
|
set_hidden(p,!(awake() && now.is_valid()));
|
|
}
|
|
inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int height) {
|
|
bool analog=t.display=="analog";
|
|
begin_extra(w,analog?(w.wide?"analog":"calendar"):"digital",width,height);
|
|
auto now=now_time?now_time():esphome::ESPTime{};
|
|
const lv_font_t *big=clock_font?clock_font:watch_value_font?watch_value_font:w.value_font;
|
|
const lv_font_t *small=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
|
|
// The date line only appears when both lines fit the card height.
|
|
bool with_date=lv_font_get_line_height(big)+2+lv_font_get_line_height(small)<=height;
|
|
int text_h=lv_font_get_line_height(big)+(with_date?2+lv_font_get_line_height(small):0);
|
|
if(!analog){
|
|
int y=std::max(0,(height-text_h)/2);
|
|
part_label(w,15,big,0,y,width,LV_TEXT_ALIGN_CENTER,time_text(now));
|
|
part_label(w,16,small,0,with_date?y+lv_font_get_line_height(big)+2:y,width,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
|
|
return;
|
|
}
|
|
// The dial keeps the same size with or without a card behind it.
|
|
int dial=std::min(height,width),cx=dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2);
|
|
for(int i=0;i<12;++i){
|
|
float a=i*3.14159265f/6;bool cardinal=i%3==0;
|
|
if(cardinal && large){
|
|
// Numerals replace the four cardinal strokes; the box is one line high and wide.
|
|
int box=lv_font_get_line_height(w.value_font)+4,ring=outer-11;
|
|
part_label(w,i,w.value_font,cx+std::lround(ring*sinf(a))-box/2,cy-std::lround(ring*cosf(a))-box/2,box,LV_TEXT_ALIGN_CENTER,i==0?"12":std::to_string(i));
|
|
continue;
|
|
}
|
|
int length=cardinal?(large?9:5):(large?5:3);
|
|
auto *p=w.points+4+2*i;
|
|
p[0]={(lv_value_precise_t)(cx+outer*sinf(a)),(lv_value_precise_t)(cy-outer*cosf(a))};
|
|
p[1]={(lv_value_precise_t)(cx+(outer-length)*sinf(a)),(lv_value_precise_t)(cy-(outer-length)*cosf(a))};
|
|
part_line(w,i,p,2,cardinal?(large?3:2):(large?2:1));
|
|
}
|
|
float hour=((now.is_valid()?now.hour%12:0)+(now.is_valid()?now.minute:0)/60.0f)*3.14159265f/6, minute=(now.is_valid()?now.minute:0)*3.14159265f/30;
|
|
w.points[0]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[1]={(lv_value_precise_t)(cx+radius*0.52f*sinf(hour)),(lv_value_precise_t)(cy-radius*0.52f*cosf(hour))};
|
|
w.points[2]={(lv_value_precise_t)cx,(lv_value_precise_t)cy};w.points[3]={(lv_value_precise_t)(cx+radius*0.82f*sinf(minute)),(lv_value_precise_t)(cy-radius*0.82f*cosf(minute))};
|
|
part_line(w,12,w.points,2,large?5:3);part_line(w,13,w.points+2,2,large?3:2);
|
|
int center=large?8:4;part_dot(w,14,cx-center/2,cy-center/2,center);
|
|
// A thin red second hand with a short tail, under the centre dot; tick() moves it every second.
|
|
w.hand_cx=cx;w.hand_cy=cy;w.hand_r=radius;w.hand_width=large?2:1;
|
|
bool new_hand=!w.parts[18];
|
|
second_hand(w,now);
|
|
if(new_hand)lv_obj_move_to_index(w.parts[18],lv_obj_get_index(w.parts[14]));
|
|
std::string day=now.is_valid()?std::to_string(now.day_of_month):"--";
|
|
if(w.wide){
|
|
int x=dial+(large?16:8),y=std::max(0,(height-text_h)/2);
|
|
part_label(w,15,big,x,y,width-x,LV_TEXT_ALIGN_CENTER,time_text(now));
|
|
part_label(w,16,small,x,with_date?y+lv_font_get_line_height(big)+2:y,width-x,LV_TEXT_ALIGN_CENTER,with_date?date_text(now):"");
|
|
return;
|
|
}
|
|
int x=dial+(large?10:6),room=std::max(1,width-x);
|
|
if(!large){
|
|
// Compact cards: "13 sep" in the large-value font beside the dial.
|
|
const lv_font_t *font=watch_value_font?watch_value_font:w.value_font;
|
|
part_label(w,16,font,x,std::max(0,int(height-lv_font_get_line_height(font))/2),room,LV_TEXT_ALIGN_CENTER,day+" "+month_short(now));
|
|
return;
|
|
}
|
|
// Calendar block: weekday over a big day number with the short month beside it.
|
|
int top=std::max(0,int(height-lv_font_get_line_height(w.value_font)-lv_font_get_line_height(big))/2);
|
|
part_label(w,15,w.value_font,x,top,room,LV_TEXT_ALIGN_CENTER,weekday_text(now));
|
|
std::string month=month_short(now);
|
|
lv_point_t day_size,month_size;
|
|
lv_text_get_size(&day_size,day.c_str(),big,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
|
|
lv_text_get_size(&month_size,month.c_str(),small,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
|
|
int gap=6,sx=x+std::max(0,int(room-(day_size.x+gap+month_size.x))/2),day_y=top+int(lv_font_get_line_height(w.value_font));
|
|
part_label(w,16,big,sx,day_y,std::min(room,(int)day_size.x+2),LV_TEXT_ALIGN_LEFT,day);
|
|
// Both baselines line up: LVGL measures base_line from the bottom of the line box.
|
|
int month_y=day_y+(big->line_height-big->base_line)-(small->line_height-small->base_line);
|
|
part_label(w,17,small,sx+day_size.x+gap,std::max(0,month_y),std::max(1,int(x+room-(sx+day_size.x+gap))),LV_TEXT_ALIGN_LEFT,month);
|
|
}
|
|
// Current conditions on the left, five day columns on the right (wide cards only).
|
|
inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int height) {
|
|
begin_extra(w,"forecast",width,height);
|
|
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
|
|
const lv_font_t *temp_font=watch_value_font?watch_value_font:w.value_font,*day_icon=mini_icon_font?mini_icon_font:w.icon_font;
|
|
int left=large?150:96,icon_h=lv_font_get_line_height(w.icon_font),temp_h=lv_font_get_line_height(temp_font),text_h=lv_font_get_line_height(w.value_font);
|
|
int block=std::max(icon_h,temp_h)+2+text_h,y=std::max(0,(height-block)/2);
|
|
char b[24];snprintf(b,sizeof(b),"%.0f°",t.current);
|
|
auto *icon=part_label(w,0,w.icon_font,0,y+(std::max(icon_h,temp_h)-icon_h)/2,icon_h+4,LV_TEXT_ALIGN_LEFT,t.available()?weather_icon(t.state):"\U000F0595");
|
|
lv_obj_set_width(icon,lv_font_get_line_height(w.icon_font)+4);
|
|
part_label(w,1,temp_font,icon_h+6,y+(std::max(icon_h,temp_h)-temp_h)/2,left-icon_h-6,LV_TEXT_ALIGN_LEFT,std::isfinite(t.current)?b:"");
|
|
part_label(w,2,w.value_font,0,y+std::max(icon_h,temp_h)+2,left-4,LV_TEXT_ALIGN_LEFT,weather_text(t.state));
|
|
int column=(width-left)/5,day_h=lv_font_get_line_height(title_font),icon_col=lv_font_get_line_height(day_icon);
|
|
for(unsigned k=0;k<5;++k){
|
|
static const Forecast no_day;int x=left+k*column;bool has=k<t.extra().forecast.size();const auto &f=has?t.extra().forecast[k]:no_day;
|
|
char temps[24];if(has && std::isfinite(f.high))snprintf(temps,sizeof(temps),std::isfinite(f.low)?"%.0f/%.0f":"%.0f",f.high,f.low);else temps[0]=0;
|
|
if(large){
|
|
int rows=day_h+icon_col+text_h,top=std::max(0,(height-rows)/2);
|
|
part_label(w,3+k*3,title_font,x,top,column,LV_TEXT_ALIGN_CENTER,has?f.day:"");
|
|
part_label(w,4+k*3,day_icon,x,top+day_h,column,LV_TEXT_ALIGN_CENTER,has?weather_icon(f.condition):"");
|
|
part_label(w,5+k*3,w.value_font,x,top+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temps);
|
|
}else{
|
|
// Two rows on the CYD: day beside its icon, then the high/low pair.
|
|
int rows=std::max(day_h,icon_col)+text_h,top=std::max(0,(height-rows)/2),day_w=column-icon_col-2;
|
|
part_label(w,3+k*3,title_font,x,top+(std::max(day_h,icon_col)-day_h)/2,day_w,LV_TEXT_ALIGN_RIGHT,has?f.day:"");
|
|
part_label(w,4+k*3,day_icon,x+day_w+2,top,icon_col,LV_TEXT_ALIGN_LEFT,has?weather_icon(f.condition):"");
|
|
part_label(w,5+k*3,w.value_font,x,top+std::max(day_h,icon_col),column,LV_TEXT_ALIGN_CENTER,temps);
|
|
}
|
|
}
|
|
}
|
|
// Smoothed trend of the manager's 24 history samples with a soft fill beneath.
|
|
inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int width,int height) {
|
|
begin_extra(w,"graph",x+width,y+height);
|
|
float minimum=INFINITY,maximum=-INFINITY;for(float v:t.history)if(std::isfinite(v)){minimum=std::min(minimum,v);maximum=std::max(maximum,v);}
|
|
lv_point_precise_t raw[24];unsigned n=0;int stroke=large?3:2,top=stroke;
|
|
for(unsigned i=0;i<t.history.size() && i<24;++i){
|
|
if(!std::isfinite(t.history[i]))continue;
|
|
float level=maximum>minimum?(t.history[i]-minimum)/(maximum-minimum):0.5f;
|
|
raw[n++]={(lv_value_precise_t)(stroke/2+i*(width-stroke-1)/23),(lv_value_precise_t)(height-stroke/2-1-level*(height-stroke-1-top))};
|
|
}
|
|
if(n==1){raw[1]=raw[0];raw[1].x=(lv_value_precise_t)(width-1);n=2;}
|
|
unsigned count=smooth(raw,n,w.points,POINT_BUFFER,width,height);
|
|
part_line(w,0,w.points,count,stroke,x,y);
|
|
w.fill_points=w.points;w.fill_count=count;w.fill_x=x;w.fill_y=y;w.fill_base=height;w.fill_opa=LV_OPA_20;
|
|
}
|
|
// Sun path: horizon, an arc from sunrise to sunset and the sun at the current
|
|
// position (or below the horizon at night). Wide cards only.
|
|
inline int minutes_of(const std::string &clock) {
|
|
unsigned h=0,m=0;return sscanf(clock.c_str(),"%u:%u",&h,&m)==2 && h<24 && m<60 ? int(h*60+m) : -1;
|
|
}
|
|
inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int height) {
|
|
begin_extra(w,"sunpath",width,height);
|
|
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
|
|
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
|
|
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
|
|
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
|
|
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.extra().sunrise);
|
|
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.extra().sunset);
|
|
auto now=now_time?now_time():esphome::ESPTime{};
|
|
int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
|
|
bool day=t.state=="above_horizon";float fraction=0.5f;
|
|
if(rise>=0 && set>=0 && minute>=0){
|
|
if(day){int span=(set-rise+1440)%1440;if(!span)span=1;fraction=std::clamp(float((minute-rise+1440)%1440)/span,0.0f,1.0f);}
|
|
else{int span=(rise-set+1440)%1440;if(!span)span=1;fraction=std::clamp(float((minute-set+1440)%1440)/span,0.0f,1.0f);}
|
|
}
|
|
const unsigned segments=40;float amplitude=day?float(horizon-top):float(height-text_h-horizon-(large?2:1));
|
|
auto *arc=w.points,*travelled=w.points+segments+1,*line=w.points+2*segments+3;
|
|
for(unsigned i=0;i<=segments;++i){
|
|
float a=3.14159265f*i/segments;
|
|
arc[i]={(lv_value_precise_t)(x0+(x1-x0)*float(i)/segments),(lv_value_precise_t)(day?horizon-sinf(a)*amplitude:horizon+sinf(a)*amplitude)};
|
|
}
|
|
unsigned filled=std::min(segments,unsigned(fraction*segments));
|
|
for(unsigned i=0;i<=filled;++i)travelled[i]=arc[i];
|
|
float sa=3.14159265f*fraction;
|
|
lv_point_precise_t sun={(lv_value_precise_t)(x0+(x1-x0)*fraction),(lv_value_precise_t)(day?horizon-sinf(sa)*amplitude:horizon+sinf(sa)*amplitude)};
|
|
travelled[filled+1]=sun;
|
|
line[0]={(lv_value_precise_t)0,(lv_value_precise_t)horizon};line[1]={(lv_value_precise_t)(width-1),(lv_value_precise_t)horizon};
|
|
uint32_t path=0xCFD8DC, accent=day?0xFF9800:0x5C6BC0, disc=day?0xFFB300:0xB0BEC5;
|
|
lv_obj_set_style_line_color(part_line(w,3,line,2,2),lv_color_hex(path),0);
|
|
lv_obj_set_style_line_color(part_line(w,4,arc,segments+1,large?2:1),lv_color_hex(path),0);
|
|
lv_obj_set_style_line_color(part_line(w,5,travelled,filled+2,large?4:3),lv_color_hex(accent),0);
|
|
int size=large?18:10,glow=size+(large?12:6);
|
|
auto *halo=part_dot(w,6,int(sun.x)-glow/2,int(sun.y)-glow/2,glow);lv_obj_set_style_bg_color(halo,lv_color_hex(disc),0);lv_obj_set_style_bg_opa(halo,LV_OPA_30,0);
|
|
lv_obj_set_style_bg_color(part_dot(w,7,int(sun.x)-size/2,int(sun.y)-size/2,size),lv_color_hex(disc),0);
|
|
if(day){w.fill_points=travelled;w.fill_count=filled+2;w.fill_x=0;w.fill_y=0;w.fill_base=horizon;w.fill_color=lv_color_hex(0xFFB300);w.fill_opa=LV_OPA_20;}
|
|
}
|
|
|
|
// ---- Direct controls on wide cards (Home Assistant entity-row style) ----
|
|
struct PanelMetrics { int key_w, key_h, radius, gap, pill_w, pill_key, slider_w, slider_h, toggle_w, toggle_h, run_pad, text_gap, ext; };
|
|
inline PanelMetrics panel_metrics(bool large) {
|
|
return large ? PanelMetrics{60,46,14,8,196,52,140,44,76,40,22,8,4} : PanelMetrics{40,34,9,4,128,36,90,30,48,26,14,6,6};
|
|
}
|
|
inline lv_obj_t *panel_obj(lv_obj_t *parent,bool clickable) {
|
|
auto *o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);
|
|
if(clickable)lv_obj_add_flag(o,LV_OBJ_FLAG_CLICKABLE);else lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);
|
|
return o;
|
|
}
|
|
// Like the custom parts: a card without controls hides the slot's panel and a later card with
|
|
// the same control set shows it again.
|
|
inline void hide_panel(Widgets &w) {
|
|
if(!w.panel)return;
|
|
if(!lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.panel,LV_OBJ_FLAG_HIDDEN);
|
|
w.panel_w=0;
|
|
if(captured_slider && captured_slider==w.control_slider)captured_slider=nullptr;
|
|
}
|
|
inline void end_panel(Widgets &w) {
|
|
if(!w.panel)return;
|
|
hide_panel(w);
|
|
if(!w.panel_mode.empty()){
|
|
lv_obj_clean(w.panel);w.keys.fill(nullptr);w.key_icons.fill(nullptr);w.key_checked.fill(-1);
|
|
w.pill=w.pill_value=w.knob=w.control_slider=nullptr;w.knob_on=-1;w.panel_mode.clear();
|
|
}
|
|
}
|
|
inline void control_event(lv_event_t *e);
|
|
// A pill key: rounded, pressed darker, checked in the accent, disabled faded.
|
|
inline lv_obj_t *panel_key(Widgets &w,unsigned n,lv_obj_t *parent,const PanelMetrics &m,int width,int height,bool transparent) {
|
|
auto *key=panel_obj(parent,true);lv_obj_set_size(key,width,height);
|
|
lv_obj_set_style_radius(key,transparent?LV_RADIUS_CIRCLE:m.radius,0);
|
|
lv_obj_set_style_bg_opa(key,transparent?LV_OPA_TRANSP:LV_OPA_COVER,0);
|
|
lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_PRESSED);
|
|
lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_CHECKED);
|
|
lv_obj_set_style_opa(key,LV_OPA_40,LV_STATE_DISABLED);
|
|
lv_obj_set_ext_click_area(key,m.ext);
|
|
size_t slot=&w-widgets.data();
|
|
lv_obj_add_event_cb(key,control_event,LV_EVENT_SHORT_CLICKED,(void*)(uintptr_t)(slot*16+n));
|
|
w.keys[n]=key;w.key_checked[n]=-1;
|
|
return key;
|
|
}
|
|
inline lv_obj_t *panel_icon(Widgets &w,unsigned n,const lv_font_t *font) {
|
|
auto *icon=lv_label_create(w.keys[n]);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_text_font(icon,font,0);lv_obj_center(icon);w.key_icons[n]=icon;return icon;
|
|
}
|
|
// Build (once per control set) and lay out the panel; returns the width it takes
|
|
// from the text, including the gap, or 0 when the card shows no panel.
|
|
inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int content_h) {
|
|
std::string mode=tile_controls::panel_kind(t);
|
|
const PanelMetrics m=panel_metrics(large);
|
|
const lv_font_t *icon_font=mini_icon_font?mini_icon_font:w.icon_font;
|
|
const lv_font_t *text_font=control_font?control_font:lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
|
|
auto d=t.domain();
|
|
if(!w.panel){w.panel=panel_obj(w.tile,false);}
|
|
if(w.panel_mode!=mode){
|
|
end_panel(w);w.panel_mode=mode;w.panel_dirty=true;
|
|
if(tile_controls::is_key_row(mode)){
|
|
for(unsigned n=0;n<3;++n){panel_key(w,n,w.panel,m,m.key_w,m.key_h,false);panel_icon(w,n,icon_font);}
|
|
}else if(mode=="setpoint"||mode=="stepper"){
|
|
w.pill=panel_obj(w.panel,false);lv_obj_set_size(w.pill,m.pill_w,m.key_h+2);
|
|
lv_obj_set_style_radius(w.pill,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.pill,LV_OPA_COVER,0);
|
|
panel_key(w,0,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,0,icon_font);lv_label_set_text(w.key_icons[0],tile_controls::glyph::MINUS);
|
|
panel_key(w,1,w.pill,m,m.pill_key,m.key_h+2,true);panel_icon(w,1,icon_font);lv_label_set_text(w.key_icons[1],tile_controls::glyph::PLUS);
|
|
// Holding -/+ keeps stepping (LVGL repeats while pressed); one call goes out after the finger rests.
|
|
for(unsigned n=0;n<2;++n)lv_obj_add_event_cb(w.keys[n],control_event,LV_EVENT_LONG_PRESSED_REPEAT,(void*)(uintptr_t)((&w-widgets.data())*16+n));
|
|
lv_obj_set_pos(w.keys[0],0,0);lv_obj_set_pos(w.keys[1],m.pill_w-m.pill_key,0);
|
|
w.pill_value=lv_label_create(w.pill);lv_obj_remove_flag(w.pill_value,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_text_font(w.pill_value,text_font,0);lv_obj_set_style_text_align(w.pill_value,LV_TEXT_ALIGN_CENTER,0);
|
|
lv_label_set_long_mode(w.pill_value,LV_LABEL_LONG_CLIP);
|
|
lv_obj_set_size(w.pill_value,m.pill_w-2*m.pill_key,lv_font_get_line_height(text_font));
|
|
lv_obj_set_pos(w.pill_value,m.pill_key,(m.key_h+2-lv_font_get_line_height(text_font))/2);
|
|
w.key_commands[0]=tile_controls::STEP_DOWN;w.key_commands[1]=tile_controls::STEP_UP;
|
|
}else if(tile_controls::is_slider(mode)){
|
|
int h=mode=="volume"?m.slider_h:m.slider_h;
|
|
auto *slider=lv_slider_create(w.panel);w.control_slider=slider;
|
|
lv_obj_remove_flag(slider,LV_OBJ_FLAG_GESTURE_BUBBLE);lv_obj_remove_flag(slider,LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_size(slider,mode=="volume"?m.slider_w:m.pill_w,h);
|
|
lv_obj_set_style_pad_all(slider,0,LV_PART_MAIN);
|
|
lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_INDICATOR);
|
|
// The handle is a short white bar inside the fill, like Home Assistant's slider.
|
|
lv_obj_set_style_radius(slider,2,LV_PART_KNOB);lv_obj_set_style_bg_color(slider,lv_color_hex(0xFFFFFF),LV_PART_KNOB);lv_obj_set_style_bg_opa(slider,LV_OPA_COVER,LV_PART_KNOB);
|
|
slider_handle(slider,mode=="volume"?m.slider_w:m.pill_w,h);
|
|
lv_obj_set_style_border_width(slider,0,LV_PART_KNOB);lv_obj_set_style_shadow_width(slider,0,LV_PART_KNOB);
|
|
lv_obj_set_ext_click_area(slider,m.ext+2);
|
|
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)w.index);
|
|
if(mode=="volume"){panel_key(w,0,w.panel,m,m.key_h,m.key_h,false);panel_icon(w,0,icon_font);w.key_commands[0]=tile_controls::MEDIA_MUTE;}
|
|
}else if(mode=="toggle"){
|
|
panel_key(w,0,w.panel,m,m.toggle_w,m.toggle_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0);
|
|
w.knob=panel_obj(w.keys[0],false);lv_obj_set_size(w.knob,m.toggle_h-8,m.toggle_h-8);lv_obj_set_y(w.knob,4);
|
|
lv_obj_set_style_radius(w.knob,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(w.knob,LV_OPA_COVER,0);lv_obj_set_style_bg_color(w.knob,lv_color_hex(0xFFFFFF),0);
|
|
w.key_commands[0]=tile_controls::TOGGLE;
|
|
}else if(mode=="run"){
|
|
const char *text=tile_controls::run_label(d);
|
|
lv_point_t size;lv_text_get_size(&size,text,text_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
|
|
panel_key(w,0,w.panel,m,std::max(m.key_w*3/2,(int)size.x+2*m.run_pad),m.key_h,false);lv_obj_set_style_radius(w.keys[0],LV_RADIUS_CIRCLE,0);
|
|
panel_icon(w,0,text_font);lv_label_set_text(w.key_icons[0],text);
|
|
w.key_commands[0]=tile_controls::RUN;
|
|
}else{end_panel(w);return 0;}
|
|
}
|
|
// Per-render contents: which keys, their icons and states; then the panel size and place.
|
|
int panel_w=0,panel_h=m.key_h;uint32_t now=esphome::millis();
|
|
auto set_checked=[&](unsigned n,bool checked){
|
|
if(w.key_checked[n]==(int)checked)return;w.key_checked[n]=checked;
|
|
if(checked)lv_obj_add_state(w.keys[n],LV_STATE_CHECKED);else lv_obj_remove_state(w.keys[n],LV_STATE_CHECKED);
|
|
if(w.key_icons[n])lv_obj_set_style_text_color(w.key_icons[n],checked?lv_color_hex(0xFFFFFF):w.panel_text,0);
|
|
};
|
|
auto set_disabled=[&](unsigned n,bool disabled){if(disabled)lv_obj_add_state(w.keys[n],LV_STATE_DISABLED);else lv_obj_remove_state(w.keys[n],LV_STATE_DISABLED);};
|
|
if(tile_controls::is_key_row(mode)){
|
|
std::array<tile_controls::Key,3> keys;unsigned count=tile_controls::keys_for(t,keys);
|
|
for(unsigned n=0;n<3;++n){
|
|
if(n>=count){lv_obj_add_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);w.key_commands[n]=tile_controls::NONE;continue;}
|
|
lv_obj_remove_flag(w.keys[n],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[n],n*(m.key_w+m.gap),0);
|
|
label(w.key_icons[n],keys[n].icon);w.key_commands[n]=keys[n].command;w.key_args[n]=keys[n].arg;
|
|
// The active mode key carries the accent; "off" stays neutral grey.
|
|
if(keys[n].checked && w.key_checked[n]!=1)lv_obj_set_style_bg_color(w.keys[n],keys[n].arg=="off"?lv_color_hex(0x9E9E9E):w.panel_accent,LV_STATE_CHECKED);
|
|
set_checked(n,keys[n].checked);set_disabled(n,keys[n].disabled);
|
|
}
|
|
panel_w=count?count*m.key_w+(count-1)*m.gap:0;
|
|
}else if(mode=="setpoint"||mode=="stepper"){
|
|
float shown=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
|
|
std::string suffix=d=="climate"?"°":t.unit.empty()?"":" "+t.unit;
|
|
label(w.pill_value,tile_controls::format_value(shown,tile_controls::edit_step(t),suffix.c_str()));
|
|
panel_w=m.pill_w;panel_h=m.key_h+2;
|
|
}else if(tile_controls::is_slider(mode)){
|
|
bool has_slider=mode!="volume" || (t.supported & tile_controls::feature::MEDIA_VOLUME_SET);
|
|
if(has_slider){
|
|
lv_obj_remove_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.control_slider,0,(panel_h-m.slider_h)/2);
|
|
// Keep the dragged value while the command is under way; HA's report takes over afterwards.
|
|
if(!lv_obj_has_state(w.control_slider,LV_STATE_PRESSED) && !(t.pending && !t.confirmed))lv_slider_set_value(w.control_slider,slider_value(t),LV_ANIM_OFF);
|
|
panel_w=mode=="volume"?m.slider_w:m.pill_w;
|
|
}else lv_obj_add_flag(w.control_slider,LV_OBJ_FLAG_HIDDEN);
|
|
if(mode=="volume"){
|
|
bool has_mute=t.supported & tile_controls::feature::MEDIA_VOLUME_MUTE;
|
|
if(has_mute){
|
|
lv_obj_remove_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN);lv_obj_set_pos(w.keys[0],panel_w?panel_w+m.gap:0,0);
|
|
label(w.key_icons[0],t.muted?tile_controls::glyph::MUTED:tile_controls::glyph::VOLUME);
|
|
if(t.muted && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED);
|
|
set_checked(0,t.muted);panel_w+=(panel_w?m.gap:0)+m.key_h;
|
|
}else lv_obj_add_flag(w.keys[0],LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
}else if(mode=="toggle"){
|
|
bool on=t.pending && !t.confirmed ? t.optimistic_on : t.state=="on";
|
|
if(on && w.key_checked[0]!=1)lv_obj_set_style_bg_color(w.keys[0],w.panel_accent,LV_STATE_CHECKED);
|
|
set_checked(0,on);
|
|
if(w.knob_on!=(int)on){w.knob_on=on;lv_obj_set_x(w.knob,on?m.toggle_w-(m.toggle_h-8)-4:4);}
|
|
panel_w=m.toggle_w;panel_h=m.toggle_h;
|
|
}else if(mode=="run"){
|
|
// The requested width: a key created in this pass has no coordinates before LVGL's layout.
|
|
panel_w=lv_obj_get_style_width(w.keys[0],LV_PART_MAIN);
|
|
}
|
|
(void)now;
|
|
if(!panel_w){hide_panel(w);return 0;}
|
|
// A panel shown again kept the colours of the card it last served.
|
|
if(lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){lv_obj_remove_flag(w.panel,LV_OBJ_FLAG_HIDDEN);w.panel_dirty=true;}
|
|
lv_obj_set_size(w.panel,panel_w,panel_h);lv_obj_set_pos(w.panel,content_w-panel_w,std::max(0,(content_h-panel_h)/2));
|
|
w.panel_w=panel_w+m.text_gap;
|
|
return w.panel_w;
|
|
}
|
|
// Colours follow the card palette; called with the rest of the palette when it changes.
|
|
inline void style_panel(Widgets &w,const Tile &t,lv_color_t accent,lv_color_t text) {
|
|
if(!w.panel || w.panel_mode.empty())return;
|
|
lv_color_t card=lv_color_hex(t.background?t.background:0xFFFFFF);
|
|
lv_color_t key_bg=lv_color_mix(card,lv_color_hex(0x000000),236);
|
|
lv_color_t key_pressed=lv_color_mix(card,lv_color_hex(0x000000),212);
|
|
w.panel_accent=accent;w.panel_text=text;
|
|
for(unsigned n=0;n<3;++n){
|
|
auto *key=w.keys[n];if(!key)continue;
|
|
bool in_pill=w.pill && lv_obj_get_parent(key)==w.pill;
|
|
set_color(key,LV_STYLE_BG_COLOR,in_pill?key_pressed:key_bg);
|
|
set_color(key,LV_STYLE_BG_COLOR,in_pill?lv_color_mix(card,lv_color_hex(0x000000),190):key_pressed,LV_STATE_PRESSED);
|
|
set_color(key,LV_STYLE_BG_COLOR,w.key_args[n]=="off" && w.panel_mode=="mode"?lv_color_hex(0x9E9E9E):accent,LV_STATE_CHECKED);
|
|
if(w.key_icons[n])set_color(w.key_icons[n],LV_STYLE_TEXT_COLOR,w.key_checked[n]==1?lv_color_hex(0xFFFFFF):text);
|
|
}
|
|
if(w.pill){set_color(w.pill,LV_STYLE_BG_COLOR,key_bg);set_color(w.pill_value,LV_STYLE_TEXT_COLOR,text);}
|
|
if(w.control_slider){
|
|
bool on=fresh() && t.active();
|
|
lv_color_t fill=on?accent:lv_color_hex(0x9E9E9E);
|
|
// The track is the fill colour at 20 % over the card, as in Home Assistant.
|
|
set_color(w.control_slider,LV_STYLE_BG_COLOR,lv_color_mix(fill,card,51),LV_PART_MAIN);
|
|
set_color(w.control_slider,LV_STYLE_BG_COLOR,fill,LV_PART_INDICATOR);
|
|
slider_bar(w.control_slider,slider_bar_shown(t,on));
|
|
}
|
|
if(w.knob){set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xD7DADF));set_color(w.keys[0],LV_STYLE_BG_COLOR,lv_color_hex(0xC5C9CF),LV_STATE_PRESSED);}
|
|
}
|
|
inline void control_event(lv_event_t *e) {
|
|
unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16;
|
|
if(slot>=widgets.size() || n>=3)return;
|
|
auto &w=widgets[slot];
|
|
if(!enabled || !fresh() || w.index>=model.count || !w.keys[n])return;
|
|
if(lv_obj_has_state(w.keys[n],LV_STATE_DISABLED))return;
|
|
uint32_t now=esphome::millis();
|
|
auto &t=model.tiles[w.index];
|
|
int command=w.key_commands[n];
|
|
bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP;
|
|
bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT;
|
|
if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding
|
|
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tap on control %u ignored: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
|
|
else if(!allowed(now,400+slot*16+n,"control "+std::to_string(slot)))return;
|
|
if(!t.available())return;
|
|
if(step){
|
|
// Local at once, tap after tap; tick() sends the last value after a short pause.
|
|
float current=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
|
|
t.edit_value=tile_controls::step_value(current,tile_controls::edit_step(t),t.minimum,t.maximum,command==tile_controls::STEP_UP?1:-1);
|
|
t.edit_since=now;t.edit_sent=false;
|
|
if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":t.unit.empty()?"":" "+t.unit;label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));}
|
|
return;
|
|
}
|
|
if(t.awaiting_action(now))return;
|
|
if(command==tile_controls::TOGGLE)t.optimistic_on=t.state!="on";
|
|
auto a=tile_controls::key_action(t,command,w.key_args[n]);
|
|
if(a.valid())action(a.service,t.entity,a.key,a.value);
|
|
}
|
|
// A busy card is covered by a translucent white sheet with a small spinner until
|
|
// Home Assistant confirms; the sheet also swallows taps meanwhile.
|
|
inline void set_busy(Widgets &w,bool busy,bool large){
|
|
if(!busy){if(w.busy)lv_obj_add_flag(w.busy,LV_OBJ_FLAG_HIDDEN);return;}
|
|
if(!w.busy){
|
|
w.busy=lv_obj_create(w.tile);lv_obj_remove_style_all(w.busy);lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_SCROLLABLE);lv_obj_add_flag(w.busy,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_bg_color(w.busy,lv_color_hex(0xFFFFFF),0);lv_obj_set_style_bg_opa(w.busy,LV_OPA_60,0);
|
|
lv_obj_set_style_radius(w.busy,lv_obj_get_style_radius(w.tile,LV_PART_MAIN),0);
|
|
#if LV_USE_SPINNER
|
|
w.spinner=lv_spinner_create(w.busy);lv_spinner_set_anim_params(w.spinner,900,200);
|
|
int size=large?30:20;lv_obj_set_size(w.spinner,size,size);lv_obj_center(w.spinner);lv_obj_remove_flag(w.spinner,LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_MAIN);lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_INDICATOR);
|
|
lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0xD9DDE2),LV_PART_MAIN);lv_obj_set_style_arc_color(w.spinner,lv_color_hex(0x1E88E5),LV_PART_INDICATOR);
|
|
lv_obj_set_style_bg_opa(w.spinner,LV_OPA_TRANSP,LV_PART_KNOB);lv_obj_set_style_pad_all(w.spinner,0,LV_PART_KNOB);
|
|
#endif
|
|
}
|
|
// Cover the whole card, padding included, at the width the card asks for: a slot that just turned
|
|
// wide is still single width in LVGL's own coordinates. Unchanged sizes cost LVGL nothing.
|
|
lv_obj_set_pos(w.busy,-lv_obj_get_style_pad_left(w.tile,LV_PART_MAIN),-lv_obj_get_style_pad_top(w.tile,LV_PART_MAIN));
|
|
lv_obj_set_size(w.busy,tile_width(w),lv_obj_get_height(w.tile));
|
|
lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_HIDDEN);
|
|
// Controls and custom parts created after the sheet would otherwise paint over its right side.
|
|
if(lv_obj_get_index(w.busy)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.busy);
|
|
}
|
|
// One card: name, status, icon, layout and palette. Everything reads the model; nothing is created
|
|
// unless the card's custom parts or controls change kind.
|
|
inline void render_slot(size_t slot) {
|
|
swipe_profile::SlotTimer slot_timer(slot);
|
|
swipe_profile::Lap lap;
|
|
auto &w=widgets[slot];
|
|
if(!w.tile || w.index>=model.count)return;
|
|
const auto &t = model.tiles[w.index];
|
|
auto d=t.domain();
|
|
label(w.title, t.name.empty() ? t.entity : t.name);
|
|
label(w.icon, icon_for(t));
|
|
bool watch=t.display=="watch";
|
|
std::string unit=watch?t.unit:"";
|
|
std::string value = t.state;
|
|
// Nothing in Home Assistant stands behind the settings card, so it says the same with the link down.
|
|
if (t.is_settings()) value = "Tap to open";
|
|
else if (!fresh() || !t.available()) value = "Unavailable";
|
|
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
|
|
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
|
|
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
|
|
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
|
|
else if (d == "timer") value = timer_text(t);
|
|
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
|
|
else if (value == "on") value = "On";
|
|
else if (value == "off") value = "Off";
|
|
else if (value == "cleaning") value = "Cleaning";
|
|
else if (value == "docked") value = "Docked";
|
|
else if (!t.unit.empty() && !watch) value += " " + t.unit;
|
|
bool pending=t.loading(esphome::millis());
|
|
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
|
|
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
|
|
// Direct controls: only a wide card in the standard layout has room for the panel.
|
|
bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
|
|
if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;}
|
|
label(w.value, value);
|
|
bool mini=t.inline_control=="slider" && !watch && t.available();
|
|
bool large_tile=lv_obj_get_height(w.tile)>80;
|
|
lap(swipe_profile::TEXT);
|
|
// Cards that replace the name/status layout entirely.
|
|
bool clock=t.is_clock(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available();
|
|
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.extra().sunrise.empty() && !t.extra().sunset.empty() && fresh() && t.available();
|
|
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
|
|
bool custom=clock||forecast||sunpath;
|
|
if(!large_tile)pad_vertical(w.tile,watch||custom||graph?2:4);
|
|
set_font(w.value,watch && watch_value_font ? watch_value_font : w.value_font);
|
|
// Both text boxes are one line high; the sizes come from the styles, not from a layout pass.
|
|
int title_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.title,LV_PART_MAIN));
|
|
int value_height=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN));
|
|
lv_obj_set_height(w.value,value_height);
|
|
int content_w=content_width(w),content_h=content_height(w);
|
|
lap(swipe_profile::LAYOUT);
|
|
set_busy(w,pending && !t.builtin(),large_tile);
|
|
lap(swipe_profile::BUSY);
|
|
for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom);
|
|
if(custom){
|
|
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);hide_panel(w);
|
|
lap(swipe_profile::GEOMETRY);
|
|
if(clock)render_clock(w,t,large_tile,content_w,content_h);
|
|
else if(sunpath)render_sunpath(w,t,large_tile,content_w,content_h);
|
|
else render_forecast(w,t,large_tile,content_w,content_h);
|
|
lap(swipe_profile::CUSTOM);
|
|
}else{
|
|
int line_gap=large_tile?2:1,text_height=title_height+line_gap+value_height;
|
|
int slider_height=large_tile?28:8;
|
|
// A single-width graph takes the slider strip; a wide graph takes the right half.
|
|
bool graph_strip=graph && !w.wide, graph_side=graph && w.wide;
|
|
int chart_w=graph_side?content_w*55/100:0;
|
|
lap(swipe_profile::GEOMETRY);
|
|
int panel_w=with_panel && !graph?layout_panel(w,t,large_tile,content_w,content_h):0;
|
|
if(!panel_w)hide_panel(w);
|
|
lap(swipe_profile::PANEL);
|
|
int header_height=(mini||graph_strip)?content_h-slider_height-(large_tile?6:3):content_h;
|
|
int text_y=std::max(0,(header_height-text_height)/2);
|
|
int circle_size=watch?(large_tile?26:18):(mini||graph_strip)?(large_tile?36:24):(large_tile?54:36);
|
|
lv_obj_set_size(w.circle,circle_size,circle_size);
|
|
const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font;
|
|
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
|
|
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
|
|
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
|
|
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
|
|
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
|
|
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
|
|
// Use the requested coordinates: LVGL getters still return the previous
|
|
// layout until its next pass when a slot changes from watch/slider to normal.
|
|
int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
|
|
lv_obj_set_width(w.title,std::max(1,text_room-text_x));
|
|
lv_obj_set_width(w.value,std::max(1,text_room-(watch?0:(mini||graph_strip)?text_x:w.value_x)));
|
|
if(watch){
|
|
int gap=large_tile?6:2,header=std::max(circle_size,title_height);
|
|
int group_y=std::max(0,(content_h-header-gap-value_height)/2);
|
|
int value_y=group_y+header+gap;
|
|
lv_obj_set_pos(w.circle,0,group_y+(header-circle_size)/2);
|
|
lv_obj_set_pos(w.title,circle_size+(large_tile?6:4),group_y+(header-title_height)/2);
|
|
lv_obj_set_width(w.title,text_room-circle_size-(large_tile?6:4));
|
|
label(w.unit,unit);
|
|
lv_point_t size;lv_text_get_size(&size,unit.c_str(),w.value_font,0,0,LV_COORD_MAX,LV_TEXT_FLAG_EXPAND);
|
|
int unit_width=unit.empty()?0:std::min((int)size.x,text_room-20);
|
|
int number_width=text_room-(unit_width?unit_width+(large_tile?6:3):0);
|
|
lv_obj_set_pos(w.value,0,value_y);lv_obj_set_width(w.value,number_width);
|
|
lv_obj_set_pos(w.unit,text_room-unit_width,value_y+value_height-lv_font_get_line_height(w.value_font));
|
|
lv_obj_set_size(w.unit,unit_width,lv_font_get_line_height(w.value_font));
|
|
set_hidden(w.unit,!unit_width);
|
|
}else lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_set_size(w.slider,content_w,slider_height);
|
|
slider_handle(w.slider,content_w,slider_height);
|
|
if(mini){lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_HIDDEN);if(!lv_obj_has_state(w.slider,LV_STATE_PRESSED) && lv_slider_get_value(w.slider)!=slider_value(t))lv_slider_set_value(w.slider,slider_value(t),LV_ANIM_OFF);}
|
|
else lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
|
|
lap(swipe_profile::GEOMETRY);
|
|
if(graph_strip)render_graph(w,t,large_tile,0,content_h-slider_height,content_w,slider_height);
|
|
else if(graph_side)render_graph(w,t,large_tile,content_w-chart_w,0,chart_w,content_h);
|
|
else hide_extra(w);
|
|
lap(swipe_profile::CUSTOM);
|
|
}
|
|
bool on = fresh() && t.active();
|
|
bool available=fresh() && t.available();
|
|
int palette_state=(available?2:0)|(on?1:0);
|
|
if (w.cached_active == palette_state && !w.panel_dirty) { lap(swipe_profile::GEOMETRY); return; }
|
|
w.cached_active = palette_state;w.panel_dirty=false;
|
|
uint32_t accent = domain_accent(t);
|
|
auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent);
|
|
if(d=="light" && on && t.has_hs_color)
|
|
color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100);
|
|
auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0);
|
|
// Darken the foreground slightly: very pale bulbs still need a visible icon.
|
|
auto icon_color=available?lv_color_mix(color,lv_color_hex(0x333333),205):lv_color_hex(0x9E9E9E);
|
|
// Off: a grey track without fill or handle, as in Home Assistant.
|
|
auto fill_color=on?color:lv_color_hex(0x9E9E9E);
|
|
set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR);
|
|
set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_mix(fill_color,lv_color_hex(0xFFFFFF),51),LV_PART_MAIN);
|
|
slider_bar(w.slider,slider_bar_shown(t,on));
|
|
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(t.background ? t.background : 0xFFFFFF));
|
|
set_number(w.tile,LV_STYLE_BORDER_WIDTH,1);
|
|
// "Background: none" hides only the card; geometry and padding stay identical,
|
|
// and the pressed flash still shows because it lives on the PRESSED state.
|
|
set_number(w.tile,LV_STYLE_BG_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
|
|
set_number(w.tile,LV_STYLE_BORDER_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
|
|
set_color(w.tile,LV_STYLE_BORDER_COLOR,t.background ? lv_color_mix(lv_color_hex(t.background),lv_color_hex(0x000000),220) : lv_color_hex(0xDDDDDD));
|
|
set_color(w.circle,LV_STYLE_BG_COLOR,circle_color);
|
|
set_color(w.icon,LV_STYLE_TEXT_COLOR,icon_color);
|
|
auto title_color=lv_color_hex(0x1B1B1B);
|
|
auto value_color=lv_color_hex(t.background ? 0x46525E : 0x616161);
|
|
set_color(w.unit,LV_STYLE_TEXT_COLOR,lv_color_hex(0x46525E));
|
|
set_color(w.title,LV_STYLE_TEXT_COLOR,title_color);
|
|
set_color(w.value,LV_STYLE_TEXT_COLOR,value_color);
|
|
style_panel(w,t,color,title_color);
|
|
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
|
|
// The sun path sets its own colours on every render.
|
|
w.fill_color=color;
|
|
for(unsigned i=0;i<w.parts.size();++i){
|
|
auto *p=w.parts[i];if(!p)continue;
|
|
bool muted=w.extra_mode=="forecast" ? i>=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
|
|
if(lv_obj_check_type(p,&lv_label_class))set_color(p,LV_STYLE_TEXT_COLOR,muted?value_color:title_color);
|
|
else if(w.extra_mode=="sunpath")continue;
|
|
else if(lv_obj_check_type(p,&lv_line_class))set_color(p,LV_STYLE_LINE_COLOR,w.extra_mode=="graph"?color:i==18?lv_color_hex(0xE53935):i<12?value_color:i==13?icon_color:title_color);
|
|
else set_color(p,LV_STYLE_BG_COLOR,i==14?icon_color:value_color);
|
|
}
|
|
lap(swipe_profile::PALETTE);
|
|
}
|
|
// What the next render() draws besides the name and the top bar: the cards of the tiles a state
|
|
// message or a tick named, or every card. A refresh that names nothing (the board's own triggers,
|
|
// such as the minute tick and time sync) draws every card.
|
|
inline uint32_t dirty_tiles=0;
|
|
inline bool dirty_all=false, dirty_header=false;
|
|
inline void refresh_tile(size_t index) { if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; if(refresh)refresh(); }
|
|
inline void refresh_header_only() { dirty_header=true; if(refresh)refresh(); }
|
|
inline void refresh_all() { dirty_all=true; if(refresh)refresh(); }
|
|
// Slots whose new page content is still to come: the page fill draws them, render() leaves them.
|
|
inline std::array<bool,SLOTS_PER_PAGE> slot_pending{};
|
|
// Cards per fill step, the swipe pass included; the next waiting slot; the step timer, and whether
|
|
// LVGL refreshed the screen since the last step.
|
|
inline size_t FILL_STEP_CARDS=2;
|
|
inline size_t fill_next=SLOTS_PER_PAGE;
|
|
inline lv_timer_t *fill_timer=nullptr;
|
|
inline bool fill_refreshed=false;
|
|
inline uint32_t fill_step_ms=0;
|
|
inline bool fill_cards(size_t cards);
|
|
// Before the first layout the screen is starting: HA connects, then ESP Screens sends the tiles.
|
|
inline void render(lv_obj_t *room) {
|
|
if (!enabled) return;
|
|
room_label=room; swipe_profile::Lap lap;
|
|
label(room, !model.configured ? (!ha_connected() ? "Connecting to Home Assistant..." : "Waiting for ESP Screens...") : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
|
|
render_header();
|
|
lap(swipe_profile::HEADER);
|
|
bool all=dirty_all || (!dirty_tiles && !dirty_header);
|
|
uint32_t tiles=dirty_tiles;
|
|
dirty_all=dirty_header=false;dirty_tiles=0;
|
|
for (size_t slot = 0; slot < SLOTS_PER_PAGE; ++slot) {
|
|
const auto &w=widgets[slot];
|
|
if(slot_pending[slot] || w.index>=model.count)continue;
|
|
if(all || (w.index<32 && (tiles>>w.index)&1))render_slot(slot);
|
|
}
|
|
}
|
|
|
|
// ---- Top bar ----
|
|
// Drawn in the board's pixels by the editor's rules (app.js barLayout): every value, the time too,
|
|
// in one font on the name's baseline; icons and the dial centred on the height of the digits; the
|
|
// gaps measured between glyph ink, so every icon sits equally close to its value.
|
|
struct HeaderSlot { lv_obj_t *icon{}, *text{}; uint32_t icon_color = 0; };
|
|
inline lv_obj_t *header_root = nullptr, *header_ring = nullptr;
|
|
inline std::array<lv_obj_t *, 2> header_hands{};
|
|
inline std::array<HeaderSlot, header_bar::MAX_ITEMS> header_slots{};
|
|
inline lv_point_precise_t header_points[4]{};
|
|
inline int header_dial_key = -1;
|
|
constexpr uint32_t HEADER_INK = 0x46525E;
|
|
inline void set_visible(lv_obj_t *obj, bool visible) {
|
|
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) != visible) return;
|
|
if (visible) lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
// Ink edges of a text from its label's left edge, and its advance (what LVGL sizes a label by).
|
|
struct TextInk { int left = 0, right = 0, advance = 0; };
|
|
inline TextInk text_ink(const lv_font_t *font, const std::string &text) {
|
|
TextInk ink;
|
|
bool first = true;
|
|
size_t i = 0;
|
|
for (uint32_t cp = header_bar::next_codepoint(text, i); cp; cp = header_bar::next_codepoint(text, i)) {
|
|
lv_font_glyph_dsc_t g;
|
|
if (!lv_font_get_glyph_dsc(font, &g, cp, 0)) continue;
|
|
if (g.box_w > 0) {
|
|
if (first) { ink.left = ink.advance + g.ofs_x; first = false; }
|
|
ink.right = ink.advance + g.ofs_x + g.box_w;
|
|
}
|
|
ink.advance += g.adv_w;
|
|
}
|
|
return ink;
|
|
}
|
|
inline lv_obj_t *header_part(lv_obj_t *parent) {
|
|
auto *part = lv_label_create(parent);
|
|
lv_obj_remove_flag(part, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_flag(part, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_set_style_text_color(part, lv_color_hex(HEADER_INK), 0);
|
|
return part;
|
|
}
|
|
// `live`: Home Assistant's values may show; without its link or the manager's feed only clocks stay.
|
|
inline void draw_header(bool live) {
|
|
if (!room_label || !time_label || !header_text_font || !header_icon_font) return;
|
|
set_visible(time_label, false);
|
|
auto *page = lv_obj_get_parent(room_label);
|
|
if (!header_root) {
|
|
header_root = lv_obj_create(page);
|
|
lv_obj_remove_style_all(header_root);
|
|
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_SCROLLABLE);
|
|
// The clock label's place in the drawing order: under the tiles, cards and overlays.
|
|
lv_obj_move_to_index(header_root, lv_obj_get_index(time_label));
|
|
for (auto &slot : header_slots) {
|
|
slot.icon = header_part(header_root);
|
|
slot.text = header_part(header_root);
|
|
lv_obj_set_style_text_font(slot.icon, header_icon_font, 0);
|
|
lv_obj_set_style_text_font(slot.text, header_text_font, 0);
|
|
slot.icon_color = HEADER_INK;
|
|
}
|
|
header_ring = lv_obj_create(header_root);
|
|
lv_obj_remove_style_all(header_ring);
|
|
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_style_radius(header_ring, LV_RADIUS_CIRCLE, 0);
|
|
lv_obj_set_style_border_color(header_ring, lv_color_hex(HEADER_INK), 0);
|
|
lv_obj_set_style_border_opa(header_ring, LV_OPA_COVER, 0);
|
|
lv_obj_add_flag(header_ring, LV_OBJ_FLAG_HIDDEN);
|
|
for (auto *&hand : header_hands) {
|
|
hand = lv_line_create(header_root);
|
|
lv_obj_remove_flag(hand, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_style_line_rounded(hand, true, 0);
|
|
lv_obj_set_style_line_color(hand, lv_color_hex(HEADER_INK), 0);
|
|
lv_obj_add_flag(hand, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
// A long name ends in dots instead of running under the items.
|
|
lv_label_set_long_mode(room_label, LV_LABEL_LONG_DOT);
|
|
}
|
|
// Geometry from the profile's own widgets: the name's margin and baseline, the clock's right margin.
|
|
const lv_font_t *name_font = lv_obj_get_style_text_font(room_label, LV_PART_MAIN);
|
|
int page_w = lv_obj_get_width(page), left = lv_obj_get_x(room_label);
|
|
// int32_t is long on the ESP32 toolchain: keep the arithmetic in int.
|
|
int width = std::max(0, page_w + static_cast<int>(lv_obj_get_style_x(time_label, LV_PART_MAIN)) - left);
|
|
int baseline = lv_obj_get_y(room_label) + (name_font->line_height - name_font->base_line);
|
|
lv_obj_set_pos(header_root, 0, 0);
|
|
lv_obj_set_size(header_root, page_w, baseline + name_font->line_height);
|
|
lv_font_glyph_dsc_t zero, dial_glyph;
|
|
if (!lv_font_get_glyph_dsc(header_text_font, &zero, '0', 0) || !zero.box_h) return;
|
|
// Twice the digits' ink centre keeps the halves exact.
|
|
int middle2 = 2 * (baseline - zero.ofs_y) - zero.box_h;
|
|
auto gaps = header_bar::gaps(zero.box_h);
|
|
// The dial is as large as a round icon (clock-outline) of the icon font.
|
|
int dial = lv_font_get_glyph_dsc(header_icon_font, &dial_glyph, 0xF0150, 0) && dial_glyph.box_h ? dial_glyph.box_h : zero.box_h * 3 / 2;
|
|
|
|
// The manager's items; until it sends them, the clock of show_clock as before the top bar.
|
|
header_bar::Bar fallback;
|
|
if (!header.received && screen_settings::current.show_clock) { fallback.items[0].kind = header_bar::Kind::clock; fallback.count = 1; }
|
|
const header_bar::Bar &bar = header.received ? header : fallback;
|
|
auto now = now_time ? now_time() : esphome::ESPTime{};
|
|
struct Part { size_t item = 0; uint32_t icon = 0; int icon_left = 0, icon_w = 0, text_left = 0, text_w = 0, width = 0; bool dial = false; std::string text; };
|
|
std::array<Part, header_bar::MAX_ITEMS> parts;
|
|
std::array<int, header_bar::MAX_ITEMS> widths{};
|
|
size_t count = 0;
|
|
for (size_t i = 0; i < bar.count; ++i) {
|
|
const auto &item = bar.items[i];
|
|
using header_bar::Kind;
|
|
if ((item.kind == Kind::text || item.kind == Kind::ago) && !live) continue;
|
|
Part p;
|
|
p.item = i;
|
|
if (item.kind == Kind::analog) { p.dial = true; p.width = dial; }
|
|
else {
|
|
p.text = item.kind == Kind::clock ? (now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : std::string("--:--"))
|
|
: item.kind == Kind::date ? (now.is_valid() ? header_bar::date_text(now.day_of_week, now.day_of_month, now.month) : std::string("—"))
|
|
: item.kind == Kind::ago ? header_bar::ago_text(item.epoch, now_epoch()) : item.text;
|
|
lv_font_glyph_dsc_t g;
|
|
if (item.icon && lv_font_get_glyph_dsc(header_icon_font, &g, item.icon, 0) && g.box_w) { p.icon = item.icon; p.icon_left = g.ofs_x; p.icon_w = g.box_w; }
|
|
auto ink = text_ink(header_text_font, p.text);
|
|
p.text_left = ink.left;
|
|
p.text_w = std::max(0, ink.right - ink.left);
|
|
p.width = p.icon_w + (p.icon && p.text_w ? gaps.icon : 0) + p.text_w;
|
|
}
|
|
if (!p.width) continue;
|
|
widths[count] = p.width;
|
|
parts[count++] = p;
|
|
}
|
|
lv_point_t name_size;
|
|
lv_text_get_size(&name_size, lv_label_get_text(room_label), name_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_EXPAND);
|
|
auto placement = header_bar::place(widths.data(), count, gaps, width, name_size.x);
|
|
lv_obj_set_width(room_label, std::max(1, std::min<int>(name_size.x, placement.name_room)));
|
|
lv_obj_set_height(room_label, lv_font_get_line_height(name_font));
|
|
|
|
std::array<bool, header_bar::MAX_ITEMS> icon_on{}, text_on{};
|
|
bool dial_on = false;
|
|
for (size_t k = placement.first; k < count; ++k) {
|
|
const auto &p = parts[k];
|
|
auto &slot = header_slots[k];
|
|
int x = left + placement.x[k];
|
|
if (p.dial) {
|
|
int top = (middle2 - dial) / 2, stroke = std::max(1, (dial + 5) / 10), hand = std::max(1, (dial * 75 + 500) / 1000);
|
|
lv_obj_set_pos(header_ring, x, top);
|
|
lv_obj_set_size(header_ring, dial, dial);
|
|
if (lv_obj_get_style_border_width(header_ring, LV_PART_MAIN) != stroke) lv_obj_set_style_border_width(header_ring, stroke, 0);
|
|
int minute = now.is_valid() ? now.hour * 60 + now.minute : 0;
|
|
int key = ((minute * 1024 + x) * 1024 + top) * 64 + dial;
|
|
if (key != header_dial_key) {
|
|
header_dial_key = key;
|
|
float centre = dial / 2.0f, hour_angle = (minute % 720) * 3.14159265f / 360, minute_angle = (minute % 60) * 3.14159265f / 30;
|
|
header_points[0] = header_points[2] = {(lv_value_precise_t)centre, (lv_value_precise_t)centre};
|
|
header_points[1] = {(lv_value_precise_t)(centre + 0.24f * dial * sinf(hour_angle)), (lv_value_precise_t)(centre - 0.24f * dial * cosf(hour_angle))};
|
|
header_points[3] = {(lv_value_precise_t)(centre + 0.34f * dial * sinf(minute_angle)), (lv_value_precise_t)(centre - 0.34f * dial * cosf(minute_angle))};
|
|
for (size_t h = 0; h < header_hands.size(); ++h) {
|
|
lv_line_set_points(header_hands[h], header_points + 2 * h, 2);
|
|
lv_obj_set_pos(header_hands[h], x, top);
|
|
set_line_width(header_hands[h], hand);
|
|
}
|
|
}
|
|
dial_on = true;
|
|
continue;
|
|
}
|
|
if (p.icon) {
|
|
lv_font_glyph_dsc_t g;
|
|
lv_font_get_glyph_dsc(header_icon_font, &g, p.icon, 0);
|
|
label(slot.icon, tile_icon::utf8(p.icon));
|
|
// LVGL draws a glyph's ink from (line_height - base_line) - box_h - ofs_y below the label top.
|
|
int ink_top = (middle2 - g.box_h) / 2;
|
|
lv_obj_set_pos(slot.icon, x - g.ofs_x, ink_top - ((header_icon_font->line_height - header_icon_font->base_line) - g.box_h - g.ofs_y));
|
|
const auto &item = bar.items[p.item];
|
|
uint32_t color = item.has_color ? item.color : HEADER_INK;
|
|
if (slot.icon_color != color) { lv_obj_set_style_text_color(slot.icon, lv_color_hex(color), 0); slot.icon_color = color; }
|
|
icon_on[k] = true;
|
|
x += p.icon_w + (p.text_w ? gaps.icon : 0);
|
|
}
|
|
if (p.text_w) {
|
|
label(slot.text, p.text);
|
|
lv_obj_set_pos(slot.text, x - p.text_left, baseline - (header_text_font->line_height - header_text_font->base_line));
|
|
text_on[k] = true;
|
|
}
|
|
}
|
|
for (size_t k = 0; k < header_slots.size(); ++k) { set_visible(header_slots[k].icon, icon_on[k]); set_visible(header_slots[k].text, text_on[k]); }
|
|
set_visible(header_ring, dial_on);
|
|
for (auto *hand : header_hands) set_visible(hand, dial_on);
|
|
}
|
|
inline void render_header() {
|
|
if (enabled) draw_header(ha_connected() && feed_alive());
|
|
}
|
|
|
|
// Inspect actual LVGL coordinates, including padding and the loaded font metrics.
|
|
inline bool check_tile_geometry() {
|
|
bool ok=true;
|
|
if(fill_next<SLOTS_PER_PAGE){ESP_LOGW("ui_test","page fill still under way at the check");fill_cards(SLOTS_PER_PAGE);}
|
|
for(auto &w:widgets){
|
|
if(!w.tile || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
|
|
lv_obj_update_layout(w.tile);
|
|
lv_area_t title,value,track,content;
|
|
lv_obj_get_content_coords(w.tile,&content);
|
|
lv_obj_get_coords(w.title,&title);lv_obj_get_coords(w.value,&value);
|
|
bool custom=lv_obj_has_flag(w.title,LV_OBJ_FLAG_HIDDEN);
|
|
bool fits=true;
|
|
if(w.wide && widgets[1].tile){
|
|
// A wide card ends exactly where the right column ends.
|
|
lv_area_t left,right;lv_obj_get_coords(w.tile,&left);lv_obj_get_coords(widgets[1].tile,&right);
|
|
int expected=lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+w.base_width;
|
|
fits=lv_obj_get_width(w.tile)==expected;
|
|
if(!fits)ESP_LOGE("ui_test","Wide width FAIL slot=%u width=%d expected=%d",(unsigned)w.index,lv_obj_get_width(w.tile),expected);
|
|
}
|
|
if(!custom){
|
|
fits=fits && title.x1>=content.x1 && title.x2<=content.x2 &&
|
|
value.x1>=content.x1 && value.x2<=content.x2 && title.y2<value.y1 && value.y2<=content.y2;
|
|
if(!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN)){
|
|
lv_obj_get_coords(w.slider,&track);
|
|
fits=fits && value.y2<track.y1 && track.y2<=content.y2;
|
|
}
|
|
if(!lv_obj_has_flag(w.circle,LV_OBJ_FLAG_HIDDEN)){
|
|
lv_area_t circle;lv_obj_get_coords(w.circle,&circle);
|
|
bool mini=!lv_obj_has_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
|
|
fits=fits && circle.x1>=content.x1 && circle.x2<title.x1 && circle.y1>=content.y1;
|
|
if(mini)fits=fits && circle.y2<track.y1;
|
|
else fits=fits && circle.y2<=content.y2;
|
|
if(!fits)ESP_LOGE("ui_test","Icon bounds slot=%u circle=%d,%d..%d,%d title_x=%d content=%d,%d..%d,%d",(unsigned)w.index,circle.x1,circle.y1,circle.x2,circle.y2,title.x1,content.x1,content.y1,content.x2,content.y2);
|
|
bool watch=w.index<model.count && model.tiles[w.index].display=="watch";
|
|
bool graph=w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN) && w.extra_mode=="graph";
|
|
if(!watch && !graph && (mini || lv_obj_get_height(w.tile)<=80)){
|
|
int header_bottom=mini?track.y1-(lv_obj_get_height(w.tile)>80?6:3)-1:content.y2;
|
|
int center_twice=content.y1+header_bottom;
|
|
fits=fits && std::abs(circle.y1+circle.y2-center_twice)<=2 &&
|
|
std::abs(title.y1+value.y2-center_twice)<=2;
|
|
}
|
|
}
|
|
if(!lv_obj_has_flag(w.unit,LV_OBJ_FLAG_HIDDEN)){
|
|
lv_area_t unit;lv_obj_get_coords(w.unit,&unit);
|
|
fits=fits && value.x2<unit.x1 && unit.x2<=content.x2 && unit.y2<=content.y2 && unit.y1>title.y2;
|
|
}
|
|
}
|
|
if(w.panel && !lv_obj_has_flag(w.panel,LV_OBJ_FLAG_HIDDEN)){
|
|
// Direct controls stay inside the card, right of the name and status, and inside their panel.
|
|
lv_area_t panel;lv_obj_get_coords(w.panel,&panel);
|
|
bool inside=panel.x1>=content.x1 && panel.x2<=content.x2 && panel.y1>=content.y1 && panel.y2<=content.y2 && (custom || (title.x2<panel.x1 && value.x2<panel.x1));
|
|
for(uint32_t i=0;i<lv_obj_get_child_count(w.panel);++i){
|
|
auto *child=lv_obj_get_child(w.panel,i);if(lv_obj_has_flag(child,LV_OBJ_FLAG_HIDDEN))continue;
|
|
lv_area_t part;lv_obj_get_coords(child,&part);
|
|
inside=inside && part.x1>=panel.x1 && part.x2<=panel.x2 && part.y1>=panel.y1 && part.y2<=panel.y2;
|
|
}
|
|
if(!inside)ESP_LOGE("ui_test","Panel bounds slot=%u mode=%s panel=%d,%d..%d,%d title_x2=%d content=%d,%d..%d,%d",(unsigned)w.index,w.panel_mode.c_str(),panel.x1,panel.y1,panel.x2,panel.y2,title.x2,content.x1,content.y1,content.x2,content.y2);
|
|
fits=fits && inside;
|
|
}
|
|
if(w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN)){
|
|
// Custom parts stay inside the card; a graph never runs into the text.
|
|
lv_area_t extra;lv_obj_get_coords(w.extra,&extra);
|
|
fits=fits && extra.x1>=content.x1 && extra.x2<=content.x2 && extra.y1>=content.y1 && extra.y2<=content.y2;
|
|
for(auto *p:w.parts){
|
|
if(!p || lv_obj_has_flag(p,LV_OBJ_FLAG_HIDDEN))continue;
|
|
lv_area_t part;lv_obj_get_coords(p,&part);
|
|
bool inside=part.x1>=content.x1 && part.x2<=content.x2 && part.y1>=content.y1 && part.y2<=content.y2;
|
|
if(!inside)ESP_LOGE("ui_test","Part bounds slot=%u mode=%s part=%d,%d..%d,%d content=%d,%d..%d,%d",(unsigned)w.index,w.extra_mode.c_str(),part.x1,part.y1,part.x2,part.y2,content.x1,content.y1,content.x2,content.y2);
|
|
fits=fits && inside;
|
|
if(w.extra_mode=="graph" && !custom)fits=fits && (w.wide?part.x1>value.x2:part.y1>value.y2);
|
|
}
|
|
}
|
|
if(!fits)ESP_LOGE("ui_test","Tile geometry FAIL slot=%u mode=%s wide=%d title_y=%d..%d value_y=%d..%d content_y=%d..%d",(unsigned)w.index,w.extra_mode.c_str(),w.wide,title.y1,title.y2,value.y1,value.y2,content.y1,content.y2);
|
|
if(w.index<model.count && model.tiles[w.index].background){
|
|
bool palette_ok=lv_color_eq(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN),lv_color_hex(model.tiles[w.index].background)) &&
|
|
lv_color_eq(lv_obj_get_style_text_color(w.title,LV_PART_MAIN),lv_color_hex(0x1B1B1B));
|
|
if(!palette_ok)ESP_LOGE("ui_test","Tile palette FAIL slot=%u",(unsigned)w.index);
|
|
fits=fits && palette_ok;
|
|
}
|
|
if(w.index<model.count){
|
|
bool bare=model.tiles[w.index].transparent;
|
|
bool opa_ok=(lv_obj_get_style_bg_opa(w.tile,LV_PART_MAIN)==LV_OPA_TRANSP)==bare && (lv_obj_get_style_border_opa(w.tile,LV_PART_MAIN)==LV_OPA_TRANSP)==bare;
|
|
if(!opa_ok)ESP_LOGE("ui_test","Tile background FAIL slot=%u transparent=%d",(unsigned)w.index,bare);
|
|
fits=fits && opa_ok;
|
|
}
|
|
ok=ok && fits;
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
inline unsigned page_count() {
|
|
std::array<Placement,MAX_TILES> placement;
|
|
return place(model,placement);
|
|
}
|
|
// Page switches feel immediate without blocking touch: the swipe pass places the new page (page
|
|
// number, card widths), draws its first two cards and gives the others a light skeleton frame, all
|
|
// in the very next frame. Every following LVGL refresh draws the next two cards, so the loop, and
|
|
// the touch polling in it, runs between the steps, and a new swipe drops a fill still under way.
|
|
// Keepalives and re-packing on the same page draw at once. Nothing is allocated.
|
|
inline lv_obj_t *nav_prev=nullptr,*nav_next=nullptr,*nav_number=nullptr;
|
|
inline int applied_page=-1;
|
|
// Slot assignment plus card widths and visibility for a page; contents are untouched.
|
|
inline int place_page(int page) {
|
|
swipe_profile::Lap lap;
|
|
std::array<Placement,MAX_TILES> placement;
|
|
int pages=place(model,placement);
|
|
page=std::clamp(page,0,pages-1);
|
|
int wide_width=widgets[0].tile && widgets[1].tile ? lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+widgets[0].base_width : 2*widgets[0].base_width;
|
|
for(size_t slot=0;slot<widgets.size();++slot){widgets[slot].index=MAX_TILES;widgets[slot].wide=false;widgets[slot].cached_active=-1;}
|
|
for(size_t i=0;i<model.count;++i)if(placement[i].page==page){auto &w=widgets[placement[i].slot];w.index=i;w.wide=model.tiles[i].wide;}
|
|
for(size_t slot=0;slot<widgets.size();++slot){
|
|
auto &w=widgets[slot];if(!w.tile)continue;
|
|
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_width(w.tile,w.wide?wide_width:w.base_width);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
|
|
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);hide_extra(w);hide_panel(w);}
|
|
}
|
|
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,pages<=1);
|
|
if(page==0)lv_obj_add_state(nav_prev,LV_STATE_DISABLED);else lv_obj_remove_state(nav_prev,LV_STATE_DISABLED);
|
|
if(page==pages-1)lv_obj_add_state(nav_next,LV_STATE_DISABLED);else lv_obj_remove_state(nav_next,LV_STATE_DISABLED);
|
|
for(auto *control:{nav_prev,nav_next})if(lv_obj_get_child_count(control))
|
|
set_number(lv_obj_get_child(control,0),LV_STYLE_TEXT_OPA,lv_obj_has_state(control,LV_STATE_DISABLED)?LV_OPA_30:LV_OPA_COVER);
|
|
label(nav_number,std::to_string(page+1)+" / "+std::to_string(pages));
|
|
lap(swipe_profile::PLACE);
|
|
return page;
|
|
}
|
|
// The skeleton frame is the empty card: its contents hidden under a sheet in the card's own colour
|
|
// (the page colour for a card without a background) that covers the content area. The sheet is a
|
|
// plain rectangle inside the card's padding, so it has no corners, border or layer to render, and
|
|
// while it is up LVGL draws nothing under it. Drawing the card takes the sheet away again.
|
|
inline lv_color_t page_color(const Widgets &w) {
|
|
for(auto *o=lv_obj_get_parent(w.tile);o;o=lv_obj_get_parent(o))
|
|
if(lv_obj_get_style_bg_opa(o,LV_PART_MAIN)>=LV_OPA_MAX)return lv_obj_get_style_bg_color(o,LV_PART_MAIN);
|
|
return lv_color_hex(0xE7E7E7);
|
|
}
|
|
inline void skeleton(Widgets &w) {
|
|
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
|
|
if(!w.veil){
|
|
w.veil=lv_obj_create(w.tile);lv_obj_remove_style_all(w.veil);
|
|
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_style_bg_opa(w.veil,LV_OPA_COVER,0);
|
|
}
|
|
const Tile *t=w.index<model.count?&model.tiles[w.index]:nullptr;
|
|
set_color(w.veil,LV_STYLE_BG_COLOR,t && t->transparent?page_color(w):lv_color_hex(t && t->background?t->background:0xFFFFFF));
|
|
lv_obj_set_pos(w.veil,0,0);
|
|
lv_obj_set_size(w.veil,content_width(w),content_height(w));
|
|
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
|
|
if(lv_obj_get_index(w.veil)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.veil);
|
|
}
|
|
inline void drop_veil(Widgets &w) {
|
|
if(w.veil && !lv_obj_has_flag(w.veil,LV_OBJ_FLAG_HIDDEN))lv_obj_add_flag(w.veil,LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
// Draws up to `cards` waiting cards in slot order; true once the page is complete.
|
|
inline bool fill_cards(size_t cards) {
|
|
for(size_t drawn=0;fill_next<SLOTS_PER_PAGE && drawn<cards;++fill_next){
|
|
if(!slot_pending[fill_next])continue;
|
|
slot_pending[fill_next]=false;render_slot(fill_next);drop_veil(widgets[fill_next]);++drawn;
|
|
}
|
|
while(fill_next<SLOTS_PER_PAGE && !slot_pending[fill_next])++fill_next;
|
|
fill_step_ms=esphome::millis();fill_refreshed=false;
|
|
if(fill_next<SLOTS_PER_PAGE)return false;
|
|
if(fill_timer)lv_timer_pause(fill_timer);
|
|
swipe_profile::content_complete();
|
|
return true;
|
|
}
|
|
inline void cancel_fill() {
|
|
fill_next=SLOTS_PER_PAGE;slot_pending.fill(false);
|
|
if(fill_timer)lv_timer_pause(fill_timer);
|
|
}
|
|
inline void fill_timer_done(lv_timer_t *) {
|
|
// One step per refresh, so a step's frame reaches the glass before the next step is drawn. A step
|
|
// that changed no pixel starts no refresh; the fill then moves on after a short wait.
|
|
if(fill_next>=SLOTS_PER_PAGE || (!fill_refreshed && esphome::millis()-fill_step_ms<40))return;
|
|
swipe_profile::FillTimer timer;
|
|
fill_cards(FILL_STEP_CARDS);
|
|
}
|
|
inline void apply_page(int page) {
|
|
cancel_fill();
|
|
applied_page=place_page(page);
|
|
for(auto &w:widgets)drop_veil(w);
|
|
if(room_label){dirty_all=true;render(room_label);}else refresh_all();
|
|
}
|
|
inline int *shown_page=nullptr;
|
|
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
|
|
nav_prev=previous;nav_next=next;nav_number=number;shown_page=&page;
|
|
int requested=page;
|
|
page=std::clamp(page,0,int(page_count())-1);
|
|
// A swipe past the first or last page: the page on screen is already right, so nothing is
|
|
// drawn again. A layout change always lands in range or on a different page.
|
|
if(requested!=page && page==applied_page)return;
|
|
if(applied_page<0 || page==applied_page || !room_label){apply_page(page);return;}
|
|
swipe_profile::begin(applied_page,page);
|
|
swipe_profile::SkeletonTimer timer;
|
|
cancel_fill();
|
|
applied_page=place_page(page);
|
|
swipe_profile::Lap lap;
|
|
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
|
|
auto &w=widgets[slot];
|
|
slot_pending[slot]=w.tile && w.index<model.count;
|
|
if(slot_pending[slot])skeleton(w);
|
|
}
|
|
for(size_t slot=SLOTS_PER_PAGE;slot<widgets.size();++slot)drop_veil(widgets[slot]);
|
|
lap(swipe_profile::SKELETON);
|
|
// The skeleton frame goes out first; the fill starts on the refresh after it.
|
|
fill_next=0;fill_step_ms=esphome::millis();fill_refreshed=false;
|
|
if(!fill_timer){
|
|
fill_timer=lv_timer_create(fill_timer_done,1,nullptr);
|
|
if(auto *display=lv_display_get_default())
|
|
lv_display_add_event_cb(display,[](lv_event_t *){fill_refreshed=true;},LV_EVENT_REFR_READY,nullptr);
|
|
}
|
|
lv_timer_resume(fill_timer);
|
|
}
|
|
|
|
#ifdef SWIPE_PROFILE
|
|
inline unsigned swipe_test_left=0, swipe_test_back=0;
|
|
inline bool swipe_test_returning=false;
|
|
inline lv_timer_t *swipe_test_timer=nullptr;
|
|
inline void swipe_test_step(lv_timer_t *timer) {
|
|
if(!shown_page || !swipe_test_left || page_count()<2){lv_timer_pause(timer);swipe_test_left=0;ESP_LOGI("swipe_prof","swipe test finished");return;}
|
|
int pages=page_count(),from=*shown_page;
|
|
*shown_page=swipe_test_returning?from-1:(from+1<pages?from+1:0);
|
|
if(*shown_page<0)*shown_page=pages-1;
|
|
show_page(*shown_page,nav_prev,nav_next,nav_number);
|
|
if(swipe_test_back && !swipe_test_returning){swipe_test_returning=true;lv_timer_set_period(timer,swipe_test_back);return;}
|
|
swipe_test_returning=false;--swipe_test_left;
|
|
lv_timer_set_period(timer,lv_timer_get_user_data(timer)?(uint32_t)(uintptr_t)lv_timer_get_user_data(timer):1200);
|
|
}
|
|
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning) {
|
|
// Cards per fill step for the measurement.
|
|
if(tuning["cards"].is<unsigned>())FILL_STEP_CARDS=std::clamp<unsigned>(tuning["cards"].as<unsigned>(),1,6);
|
|
ESP_LOGI("swipe_prof","tuning: %u cards per step",(unsigned)FILL_STEP_CARDS);
|
|
swipe_test_left=std::min(count,200u);swipe_test_back=back_ms;swipe_test_returning=false;
|
|
interval_ms=std::clamp(interval_ms,200u,10000u);
|
|
if(!swipe_test_timer)swipe_test_timer=lv_timer_create(swipe_test_step,interval_ms,(void*)(uintptr_t)interval_ms);
|
|
lv_timer_set_user_data(swipe_test_timer,(void*)(uintptr_t)interval_ms);
|
|
lv_timer_set_period(swipe_test_timer,interval_ms);lv_timer_reset(swipe_test_timer);lv_timer_resume(swipe_test_timer);
|
|
ESP_LOGI("swipe_prof","swipe test: %u page switches every %u ms, back after %u ms",swipe_test_left,interval_ms,back_ms);
|
|
}
|
|
#endif
|
|
inline uint32_t last_live_second=0;
|
|
inline int last_clock_minute=-2;
|
|
inline bool was_fresh=false;
|
|
inline void tick() {
|
|
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
|
|
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
|
|
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
|
|
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):t.domain()=="cover"?cover_status_line(t):detail_state(t);
|
|
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
|
|
// The vacuum card lays its state out with the room and battery beside it, so a new text draws the
|
|
// card again; once Home Assistant answered, the new state says enough.
|
|
if(detail_badge_status && t.domain()=="vacuum"){
|
|
status=waiting && !t.confirmed?"Command sent...":detail_state(t);
|
|
if(status!=lv_label_get_text(detail_badge_status))refresh_detail(detail_index);
|
|
}else if(detail_badge_status)label(detail_badge_status,status);
|
|
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){
|
|
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
|
|
else lv_obj_remove_state(detail_switch,LV_STATE_CHECKED);
|
|
}
|
|
}
|
|
if(!enabled)return;
|
|
// Only the cards that change are drawn again: a clock or a finished command redraws its own card.
|
|
bool redraw=false;
|
|
auto card=[&](size_t index){ if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; redraw=true; };
|
|
// HA dropping or returning and the feed timing out change every card at once.
|
|
bool now_fresh=fresh();
|
|
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
|
|
for(size_t i=0;i<model.tiles.size();++i){
|
|
auto &t=model.tiles[i];if(!t.pending || t.loading(esphome::millis()))continue;
|
|
t.pending=false;card(i);
|
|
// A vacuum chip Home Assistant never confirmed goes back to what the robot reports.
|
|
bool sent=false;if(auto *x=t.extra_ptr())for(auto &c:x->choices){sent=sent||!c.sent.empty();c.sent.clear();}
|
|
if(sent)refresh_detail(i);
|
|
}
|
|
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
|
|
for(size_t i=0;i<model.count;++i){
|
|
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
|
|
uint32_t now=esphome::millis();
|
|
if(!t.edit_sent){
|
|
if(now-t.edit_since<700 || t.awaiting_action(now))continue;
|
|
auto a=tile_controls::edit_action(t,t.edit_value);
|
|
if(a.valid()){t.edit_sent=true;t.edit_since=now;action(a.service,t.entity,a.key,a.value);}else t.edit_value=NAN;
|
|
}else if(now-t.edit_since>10000){t.edit_value=NAN;card(i);}
|
|
}
|
|
// Running timers advance once per second without any HA traffic; clocks show hours and minutes,
|
|
// so they are drawn again only when the minute (or the time's validity) changes.
|
|
uint32_t second=esphome::millis()/1000;
|
|
if(second!=last_live_second){
|
|
last_live_second=second;
|
|
auto now=now_time?now_time():esphome::ESPTime{};
|
|
int minute=now.is_valid()?now.day_of_year*1440+now.hour*60+now.minute:-1;
|
|
bool new_minute=minute!=last_clock_minute;last_clock_minute=minute;
|
|
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
|
|
auto &w=widgets[slot];if(!w.tile || w.index>=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
|
|
const auto &t=model.tiles[w.index];
|
|
if((t.is_clock() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
|
|
// The second hand moves on its own: only its line is redrawn, and it hides during standby.
|
|
else if(w.parts[18] && w.points && t.is_clock() && t.display=="analog")second_hand(w,now);
|
|
}
|
|
}
|
|
if(redraw && refresh)refresh();
|
|
}
|
|
inline std::string vacuum_option(unsigned index) {
|
|
if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {};
|
|
auto &tile = model.tiles[active_index];
|
|
const auto &speeds = tile.extra().fan_speeds;
|
|
return index < speeds.size() ? speeds[index] : "";
|
|
}
|
|
}
|