Free memory on the CYD and drop the old manual profile (0.2.50)

The CYD restarted under load: a slider fill rounded less than its track made
LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and
when that allocation failed the refresh retried until the task watchdog fired.
Track and fill now share one radius, and tests/test_layer_free.py keeps every
layer-making style out of the profiles, the packages and the firmware headers.

A tile keeps only the fields every card uses; climate modes, select options,
weather, sun and timer times, the media title and the vacuum rows moved to an
Extra block that exists only while a state needs one, and the state copy became
a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes.

The manual profile that bound tiles to fixed entities in the YAML is gone:
331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home
Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its
guides and helper scripts, and the five stale header copies in the repository
root that two C++ tests were compiling instead of the components. esphome config
of both Easy profiles is unchanged by that cleanup.

tile_scroll and the wake overlay no longer light up on a tap, and the debug
sensor min_free reports the lowest free heap since boot. On the bench CYD static
RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with
78 KB as the lowest point over thirteen hours without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 08:06:41 +02:00
parent 79a84a4c77
commit 08197c4ae1
45 changed files with 854 additions and 12896 deletions
+6 -3
View File
@@ -192,10 +192,13 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
lv_obj_set_style_outline_opa(row.slider, LV_OPA_20, LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : 2000, i == 0 ? 360 : 6500);
} else {
// Track and fill corners, handle and shortest fill as in Home Assistant's 42 px control slider.
// Corners, handle and shortest fill as in Home Assistant's 42 px control slider. The fill keeps
// the track's radius: a smaller one makes LVGL draw the fill into its own full-size buffer on
// every redraw (20 KB on the CYD) and clip it to the track.
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_style_radius(row.slider, track_h * 12 / 42, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, track_h * 8 / 42, LV_PART_INDICATOR);
int corner = track_h * 12 / 42;
lv_obj_set_style_radius(row.slider, corner, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, corner, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFDEFC3), LV_PART_MAIN);
+77 -27
View File
@@ -1,6 +1,7 @@
#pragma once
#include <algorithm>
#include <array>
#include <memory>
#include <string>
#include <vector>
#include <cmath>
@@ -26,8 +27,20 @@ inline bool valid_entity(const std::string &entity) {
if (domain == allowed) return true;
return false;
}
inline std::string state_revision(const std::string &state, const std::string &attributes) {
return state + "\n" + attributes;
// A tile keeps a fingerprint (FNV-1a) of its last state message instead of a copy of it. It is also an
// ArduinoJson writer: the firmware hashes the attributes while serializing them, without a string.
struct Fingerprint {
uint32_t value = 2166136261u;
size_t write(uint8_t c) { value = (value ^ c) * 16777619u; return 1; }
size_t write(const uint8_t *data, size_t size) { for (size_t i = 0; i < size; ++i) write(data[i]); return size; }
void add(const std::string &text) { write(reinterpret_cast<const uint8_t *>(text.data()), text.size()); }
};
inline uint32_t state_revision(const std::string &state, const std::string &attributes) {
Fingerprint f;
f.add(state);
f.write('\n');
f.add(attributes);
return f.value;
}
struct Forecast { std::string day, condition; float high = NAN, low = NAN, rain = NAN, mm = NAN; };
struct Hour { std::string time, condition; float temp = NAN, rain = NAN, mm = NAN; };
@@ -41,59 +54,96 @@ struct Choice {
std::string entity, current, roles, sent;
std::vector<std::string> values, labels;
};
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode;
std::array<std::string, 4> fan_speeds;
unsigned fan_speed_count = 0;
// What only some tiles carry: climate modes, a select's options, weather, sun and timer times, a media
// title, the vacuum rows. A light or a sensor has none of it, so a tile holds this block only while its
// state needs one: twenty tiles with these fields inline took 24 KB of the CYD's RAM, mostly empty.
struct Extra {
// Climate: the modes as JSON lists, the current fan and swing mode, and what it is doing now.
std::string hvac_modes, fan_modes, swing_modes, fan_mode, swing_mode, hvac_action;
// A select's options, at most eight.
std::vector<std::string> options;
// Weather: up to five days and eight hours.
std::vector<Forecast> forecast;
std::vector<Hour> hours;
float wind = NAN, feels = NAN;
std::string wind_unit;
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
std::string media_title;
// Vacuum: its own speeds (at most four) and speed, the mode, water and suction rows (see Choice), and
// from sensors of its device the room it is in and whether it charges.
std::vector<std::string> fan_speeds;
std::string fan_speed;
std::vector<Choice> choices;
std::string room;
bool charging = false;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
bool empty() const {
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging;
}
};
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
struct ExtraBox {
std::unique_ptr<Extra> ptr;
ExtraBox() = default;
ExtraBox(const ExtraBox &other) : ptr(other.ptr ? new Extra(*other.ptr) : nullptr) {}
ExtraBox &operator=(const ExtraBox &other) { if (this != &other) ptr.reset(other.ptr ? new Extra(*other.ptr) : nullptr); return *this; }
ExtraBox(ExtraBox &&) noexcept = default;
ExtraBox &operator=(ExtraBox &&) noexcept = default;
};
struct Tile {
std::string entity, name, state, unit, modes;
float brightness = NAN, percentage = NAN, position = NAN;
float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f;
int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0;
bool received = false;
bool has_hs_color = false;
int saturation = 0;
std::string tap = "auto", display = "standard", inline_control = "none", media_title;
std::string tap = "auto", display = "standard", inline_control = "none";
bool wide = false;
// Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card.
std::string controls, device_class, hvac_action;
std::string controls, device_class;
bool muted = false;
// A -/+ edit shows at once and is sent as one call after a short pause; the
// value stays until Home Assistant reports it (or a timeout clears it).
float edit_value = NAN; uint32_t edit_since = 0; bool edit_sent = false;
// Knob position a toggle shows while its command is under way.
bool optimistic_on = false;
std::array<std::string, 8> options;
unsigned option_count = 0, history_hours = 24;
std::array<float,24> history{};
// A sensor's graph: 24 samples over `history_hours`, empty without one.
unsigned history_hours = 24;
std::vector<float> history;
bool has_history = false;
// Weather only: up to five days and eight hours; empty vectors cost nothing on the other tiles.
std::vector<Forecast> forecast;
std::vector<Hour> hours;
float wind = NAN, feels = NAN;
std::string wind_unit;
// When a scene, script or button last ran (unix time), pre-computed by the manager.
uint32_t last_run = 0;
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
float battery = NAN, volume = NAN;
// Vacuum only: mode, water and suction rows (see Choice); an empty vector costs nothing elsewhere.
// `room` is where the robot is and `charging` whether it charges, both from sensors of its device.
std::vector<Choice> choices;
std::string room;
bool charging = false;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
const Choice *choice(char kind) const { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
uint32_t supported = 0, background = 0;
bool transparent = false; // "Background: none": card fill and border hidden, contents unchanged.
std::string icon; // UTF-8 glyph of a chosen icon the icon fonts contain; empty keeps the domain icon.
std::string revision, pending_revision;
uint32_t revision = 0, pending_revision = 0; // state_revision() fingerprints
uint32_t pending_since = 0;
bool pending = false, confirmed = false, local_feedback = false;
ExtraBox extra_box;
const Extra &extra() const { static const Extra none; return extra_box.ptr ? *extra_box.ptr : none; }
Extra *extra_ptr() { return extra_box.ptr.get(); }
Extra &edit_extra() { if (!extra_box.ptr) extra_box.ptr.reset(new Extra()); return *extra_box.ptr; }
// A state message's extras replace the block: it stays allocated while the tile needs one and is freed
// when a state brings none.
void set_extra(Extra &&next) {
if (next.empty()) extra_box.ptr.reset();
else if (extra_box.ptr) *extra_box.ptr = std::move(next);
else extra_box.ptr.reset(new Extra(std::move(next)));
}
Choice *choice(char kind) { return extra_box.ptr ? extra_box.ptr->choice(kind) : nullptr; }
const Choice *choice(char kind) const { for (auto &c : extra().choices) if (c.kind == kind) return &c; return nullptr; }
bool is_switch() const { return domain()=="switch" || domain()=="input_boolean"; }
bool loading(uint32_t now) const { return pending && (now-pending_since < (is_switch()?150u:1000u) || (!confirmed && !local_feedback && now-pending_since < 6000)); }
bool awaiting_action(uint32_t now) const { return loading(now) && !local_feedback; }
void begin(uint32_t now, bool local=false) { pending=true; pending_since=now; confirmed=false; local_feedback=local; pending_revision=revision; }
void observe(const std::string &next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
void observe(uint32_t next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool builtin() const { return domain() == "screen"; }
bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); }
+78 -71
View File
@@ -276,13 +276,15 @@ inline std::string receive(const std::string &payload) {
if (!model.accepts(index, entity)) { result = "Error: outdated tile"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
// ArduinoJson clears its destination string: serialize attributes first, then add state.
std::string attributes; serializeJson(a, attributes);
// The extras count too: a vacuum's mode or water select changes only there.
if (!root["x"].isNull()) { std::string more; serializeJson(root["x"], more); attributes += more; }
std::string revision = state_revision(string(root["state"]), attributes);
// 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);
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"];
@@ -301,39 +303,38 @@ inline std::string receive(const std::string &payload) {
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.
// 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"];
tile.forecast.clear();
Extra next;
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
if (tile.forecast.size() == 5) break;
tile.forecast.emplace_back(); auto &f = tile.forecast.back();
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"]);
}
tile.hours.clear();
if (extra["hours"].is<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
if (tile.hours.size() == 8) break;
tile.hours.emplace_back(); auto &h = tile.hours.back();
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;
tile.sunrise = string(extra["rise"], 5); tile.sunset = string(extra["set"], 5);
tile.timer_end = extra["end"].is<unsigned>() ? extra["end"].as<uint32_t>() : 0;
tile.duration = string(extra["dur"], 16); tile.remaining = string(extra["rem"], 16);
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.fill(NAN);unsigned j=0;
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);
}
tile.option_count=0;
} 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(tile.option_count==8)break;tile.options[tile.option_count++]=string(option,48); }
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);tile.hvac_action=string(a["hvac_action"],24);
tile.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
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);
@@ -350,12 +351,12 @@ inline std::string receive(const std::string &payload) {
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"]);tile.wind=number(a["wind_speed"]);tile.wind_unit=string(a["wind_speed_unit"],8);tile.feels=number(a["apparent_temperature"]);
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"]);
tile.hvac_modes = list(a["hvac_modes"]);
tile.fan_modes = list(a["fan_modes"]); tile.swing_modes = list(a["swing_modes"]);
tile.fan_mode = string(a["fan_mode"], 48); tile.swing_mode = string(a["swing_mode"], 48);
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);
@@ -365,19 +366,17 @@ inline std::string receive(const std::string &payload) {
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);
tile.fan_speed = string(a["fan_speed"], 48);
tile.fan_speed_count = 0;
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 (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
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.choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent);
tile.choices.clear();
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;
@@ -388,13 +387,14 @@ inline std::string receive(const std::string &payload) {
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()) tile.choices.push_back(std::move(row));
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(tile); }
for (auto &[kind, value] : tapped) if (auto *c = tile.choice(kind)) if (c->current != value) c->sent = value;
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"]);
tile.charging = extra["chg"].is<int>() && extra["chg"].as<int>() == 1;
tile.room = string(extra["room"], 32);
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;
@@ -530,7 +530,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
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.option_count)action(t.domain()+".select_option",t.entity,"option",t.options[cmd-30]);
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]);
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);
@@ -577,6 +577,7 @@ inline lv_obj_t *detail_card(int x,int y,int w,int h){
// 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;
@@ -587,7 +588,7 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
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>(t.hours.size(),6);
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;
@@ -601,15 +602,15 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
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(t.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",t.feels);details=b;}
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(t.wind)){snprintf(b,sizeof(b),"%.0f %s",t.wind,t.wind_unit.empty()?"km/h":t.wind_unit.c_str());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=t.hours[i];int x=card_pad+i*col;
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);
@@ -618,16 +619,16 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!t.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(!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)t.forecast.size();
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<t.forecast.size();++i){
const auto &f=t.forecast[i];int ry=row_pad+i*row,tcy=ry+(row-text_h)/2,scy=ry+(row-small_h)/2;
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);
@@ -689,7 +690,7 @@ inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font
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.charging && bolt_font?text_width("\U000F0241",bolt_font):0;
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);
@@ -773,11 +774,11 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
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.room.empty();
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.room,text_x,ty,text_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);ty+=text_h;}
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);
@@ -793,8 +794,8 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
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.room.empty() && words-state_w>24)
detail_text(detail_root," · "+t.room,pad+dot+10+state_w,y,words-state_w,text,LV_TEXT_ALIGN_LEFT,0x5F6368);
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);
}
@@ -876,15 +877,16 @@ inline void show_detail(unsigned index){
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<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;
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"){
for(unsigned i=0;i<t.option_count;++i)detail_button(t.options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
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.media_title,pad,top,width-2*pad);top+=large?45:25;
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;
}
@@ -898,8 +900,8 @@ inline void show_detail(unsigned index){
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.sunrise,pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
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);
}
}
}
@@ -988,9 +990,10 @@ inline std::string last_run_text(uint32_t epoch) {
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
}
inline std::string timer_text(const Tile &t) {
if (t.state == "active") { uint32_t now = now_epoch(); return countdown(t.timer_end > now && now ? t.timer_end - now : 0); }
if (t.state == "paused") return "Paused " + countdown(duration_seconds(t.remaining));
return t.duration.empty() ? "Off" : countdown(duration_seconds(t.duration));
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"};
@@ -1060,8 +1063,11 @@ 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;
set_number(slider, LV_STYLE_RADIUS, height > 20 ? height * 12 / 42 : LV_RADIUS_CIRCLE, LV_PART_MAIN);
set_number(slider, LV_STYLE_RADIUS, height > 20 ? height * 8 / 42 : LV_RADIUS_CIRCLE, LV_PART_INDICATOR);
// 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);
@@ -1324,7 +1330,7 @@ inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int he
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){
int x=left+k*column;bool has=k<t.forecast.size();const auto &f=t.forecast[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);
@@ -1345,7 +1351,7 @@ inline void render_graph(Widgets &w,const Tile &t,bool large,int x,int y,int wid
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<24;++i){
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))};
@@ -1366,10 +1372,10 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
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.sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.sunset);
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.sunrise),set=minutes_of(t.sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
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);}
@@ -1651,7 +1657,7 @@ inline void render_slot(size_t slot) {
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.sunrise.empty() && !t.sunset.empty() ? t.sunrise+" - "+t.sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
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";
@@ -1670,8 +1676,8 @@ inline void render_slot(size_t slot) {
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.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.sunrise.empty() && !t.sunset.empty() && fresh() && t.available();
bool clock=t.builtin(), 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);
@@ -2276,7 +2282,7 @@ inline void tick() {
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;for(auto &c:t.choices){sent=sent||!c.sent.empty();c.sent.clear();}
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.
@@ -2310,6 +2316,7 @@ inline void tick() {
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];
return index < tile.fan_speed_count ? tile.fan_speeds[index] : "";
const auto &speeds = tile.extra().fan_speeds;
return index < speeds.size() ? speeds[index] : "";
}
}
+18 -17
View File
@@ -136,7 +136,7 @@ inline const char *media_state_text(const std::string &state) {
inline std::string status_text(const Tile &t) {
auto d = t.domain(); char b[48];
if (d == "climate") {
std::string text = climate_action_text(t.hvac_action);
std::string text = climate_action_text(t.extra().hvac_action);
if (text.empty()) text = climate_mode_text(t.state);
if (std::isfinite(t.current)) { snprintf(b, sizeof(b), " · %.1f°", t.current); text += b; }
return text;
@@ -147,7 +147,8 @@ inline std::string status_text(const Tile &t) {
return text;
}
if (d == "media_player") {
std::string text = (t.state == "playing" || t.state == "paused") && !t.media_title.empty() ? t.media_title : media_state_text(t.state);
const std::string &title = t.extra().media_title;
std::string text = (t.state == "playing" || t.state == "paused") && !title.empty() ? title : media_state_text(t.state);
if (std::isfinite(t.volume) && (t.supported & feature::MEDIA_VOLUME_SET)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.volume * 100)); text += b; }
return text;
}
@@ -184,19 +185,19 @@ inline unsigned keys_for(const Tile &t, std::array<Key, 3> &out) {
} else if (c == "mode") {
// Three keys fit beside the name; Home Assistant lists modes in device order.
for (const char *mode : {"off", "heat", "cool", "heat_cool", "auto", "dry", "fan_only"})
if (n < 3 && has_mode(t.hvac_modes, mode)) add(mode_icon(mode), HVAC_MODE, false, t.state == mode, mode);
if (n < 3 && has_mode(t.extra().hvac_modes, mode)) add(mode_icon(mode), HVAC_MODE, false, t.state == mode, mode);
} else if (c == "chevrons") {
add(glyph::LEFT, SELECT_PREVIOUS, t.option_count < 2);
add(glyph::RIGHT, SELECT_NEXT, t.option_count < 2);
add(glyph::LEFT, SELECT_PREVIOUS, t.extra().options.size() < 2);
add(glyph::RIGHT, SELECT_NEXT, t.extra().options.size() < 2);
}
return n;
}
inline std::string neighbour_option(const Tile &t, int direction) {
if (!t.option_count) return {};
int current = 0;
for (unsigned i = 0; i < t.option_count; ++i) if (t.options[i] == t.state) current = i;
int next = (current + direction + (int) t.option_count) % (int) t.option_count;
return t.options[next];
const auto &options = t.extra().options;
if (options.empty()) return {};
int current = 0, count = (int) options.size();
for (int i = 0; i < count; ++i) if (options[i] == t.state) current = i;
return options[(current + direction + count) % count];
}
inline const char *run_label(const std::string &domain) {
if (domain == "scene") return "Activate";
@@ -271,15 +272,15 @@ inline std::string speed_label(const std::string &speed) {
}
// The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four
// of the vacuum's own list. Its value is always the vacuum's fan_speed.
inline void settle_suction(Tile &t) {
auto *row = t.choice('s');
if (!row && t.fan_speed_count) {
inline void settle_suction(runtime_tiles::Extra &x) {
auto *row = x.choice('s');
if (!row && !x.fan_speeds.empty()) {
runtime_tiles::Choice legacy; legacy.kind = 's';
for (unsigned i = 0; i < t.fan_speed_count; ++i) { legacy.values.push_back(t.fan_speeds[i]); legacy.labels.push_back(speed_label(t.fan_speeds[i])); }
t.choices.push_back(std::move(legacy));
row = &t.choices.back();
for (const auto &speed : x.fan_speeds) { legacy.values.push_back(speed); legacy.labels.push_back(speed_label(speed)); }
x.choices.push_back(std::move(legacy));
row = &x.choices.back();
}
if (row) row->current = t.fan_speed;
if (row) row->current = x.fan_speed;
}
// The service call behind a chip: a select option on the device, or the vacuum's own fan speed.
inline Action choice_action(const Tile &t, char kind, const std::string &value) {