Files
homeassistant_espscreen/components/smart_display/runtime_model.h
T
Max Gramser f13d8ad0ed A media card like a phone's now playing, with the album cover on a Guition (0.2.77)
The card behind a media tile shows the album cover, the title, the artist and
the album, a progress bar that runs while the track plays with the times at its
ends, round keys for previous, play or pause and next, and a volume row with a
mute key, a wide slider and the percentage. Keys the player lacks stay faded,
an off player shows one power key, a stream without a length shows no bar, and
the CYD shows the player's icon in the cover's place. A media tile over the
whole page is the same card under the tile's head. media_card.h holds the
geometry, tests/test_media_card.cpp checks every shape.

The cover comes the way camera images do: the screen asks with the size and
the colour behind the corners, the app fetches the picture from Home Assistant,
cuts it square, rounds the corners over that colour and serves it on port 8098;
the Guition loads it once and asks again only when the picture's mark in the
state changes. Firmware 0.2.64.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:04:44 +02:00

402 lines
23 KiB
C++

#pragma once
#include <algorithm>
#include <array>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <cmath>
#include <cstdint>
// The tiles of a screen live on the heap, as many as the layout has (firmware 0.2.62+): a screen with twelve
// tiles pays for twelve. On a board with PSRAM ESPHome's allocator puts them there, so the internal RAM the
// WiFi stack and LVGL need stays free; without PSRAM it falls back to the internal heap. The host tests
// and the host render build have neither, so they take the standard allocator.
#if __has_include("esphome/core/defines.h")
#include "esphome/core/defines.h"
#endif
#ifdef USE_ESP32
#include "esphome/core/helpers.h"
#endif
namespace runtime_tiles {
// Eight pages of six slots: a screen holds at most one tile per slot (firmware 0.2.62+; twenty before).
constexpr size_t SLOTS_PER_PAGE = 6;
// Explicit grid positions (0.2.26+) address at most eight pages of six slots.
constexpr size_t MAX_PAGES = 8;
constexpr size_t MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
constexpr size_t MAX_TILES = MAX_SLOTS;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == entity.size()) return false;
for (size_t i = 0; i < entity.size(); ++i)
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot);
// screen.* are built-in cards without a Home Assistant entity behind them; screen.page_<n> (firmware 0.2.62+)
// only goes to page n, one such tile per page, so an entity still appears once on a screen.
if (domain == "screen") {
if (entity == "screen.clock" || entity == "screen.settings") return true;
return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0 && entity[12] >= '1' && entity[12] <= static_cast<char>('0' + MAX_PAGES);
}
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person", "camera", "image"})
if (domain == allowed) return true;
return false;
}
// An action as Home Assistant names it (domain.action: lowercase letters, digits, underscores), of any integration.
inline bool valid_action(const std::string &action) {
if (action.size() > 64) return false;
auto dot = action.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == action.size() || action.find('.', dot + 1) != std::string::npos) return false;
for (size_t i = 0; i < action.size(); ++i)
if (i != dot && !(action[i] >= 'a' && action[i] <= 'z') && !(action[i] >= '0' && action[i] <= '9') && action[i] != '_') return false;
return true;
}
// A tile keeps a fingerprint (FNV-1a) of its last state message instead of a copy of it. It is also an
// ArduinoJson writer: the firmware hashes the attributes while serializing them, without a string.
struct Fingerprint {
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; };
// One row of choices on a vacuum card (firmware 0.2.39+), found by the manager on the robot's device:
// kind 'm' a cleaning mode select (Roborock: vacuum, mop or both), 'w' a water or mop intensity
// select, 's' the suction speeds of the vacuum itself. `values` go to Home Assistant, `labels` are
// shown; a cleaning mode carries one role letter per value (v vacuum only, m mop only, b both,
// a automatic: the robot or the app decides suction and water). `sent` is the value just tapped.
struct Choice {
char kind = 0;
std::string entity, current, roles, sent;
std::vector<std::string> values, labels;
};
// 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;
// The media card (firmware 0.2.64+, app 0.2.77+): the artist and the album, the track's length and where it was when
// Home Assistant last said so (seconds, and that moment as an epoch), and a short mark of the cover picture, empty
// when the player shows none. The mark changes with the picture: the card fetches a new cover when it does.
std::string media_artist, media_album, media_picture;
uint32_t media_duration = 0, media_position = 0, media_position_at = 0;
// 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;
// Cover (firmware 0.2.50+): the tilt of its slats, 0 closed to 100 open.
float tilt = NAN;
// A tap that performs a Home Assistant action of the tile's own choosing (firmware 0.2.58+): the action, its data as
// text, and the values Home Assistant renders itself (numbers, lists, true or false) as templates.
std::string action;
std::vector<std::pair<std::string, std::string>> action_data, action_templates;
// Home Assistant's word for the state where the screen has none of its own (app 0.2.67+): "Open", "Playing", "Rinsing".
std::string state_word;
Choice *choice(char kind) { for (auto &c : choices) if (c.kind == kind) return &c; return nullptr; }
bool empty() const {
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() &&
media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() &&
action_templates.empty() && state_word.empty();
}
};
// The Extra of a tile on the heap, copied along with the tile like an ordinary member.
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";
// Double width takes a row; full (firmware 0.2.62+) takes the whole page, all six slots, and is also wide.
bool wide = false, full = false;
// Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card.
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;
// A slider the finger let go stays where it was put while the light fades towards it (firmware 0.2.60+): the value
// sent, in the attribute's own unit (brightness 0-255, a fan's percent, a volume 0-1), the value Home Assistant
// reported meanwhile, and when the last of those came.
float slider_sent = NAN, slider_real = NAN;
uint32_t slider_sent_at = 0, slider_state_at = 0;
// A tap that switched this tile is waiting; `optimistic_prev_on` is the stand to put back on a refusal.
bool optimistic_tap = false, optimistic_prev_on = false;
// A sensor's graph: 24 samples over `history_hours`, empty without one.
unsigned history_hours = 24;
std::vector<float> history;
bool has_history = false;
// When a scene, script or button last ran (unix time), pre-computed by the manager.
uint32_t last_run = 0;
float battery = NAN, volume = NAN;
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.
uint32_t revision = 0, pending_revision = 0; // state_revision() fingerprints
uint32_t pending_since = 0;
// When Home Assistant answered "it worked" for a watched call (firmware 0.2.59+); 0 while no answer came.
uint32_t answered_at = 0;
bool pending = false, confirmed = false, local_feedback = false;
// When Home Assistant refused the action a tap sent (firmware 0.2.58+); the tile says so for a moment.
uint32_t refused_at = 0;
ExtraBox extra_box;
const Extra &extra() const { static const Extra none; return extra_box.ptr ? *extra_box.ptr : none; }
Extra *extra_ptr() { return extra_box.ptr.get(); }
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"; }
// Waiting for Home Assistant. It answered in 342-599 ms for every command measured on a real installation, so the
// tile draws nothing for the first 400 ms: a command that lands looks instant (firmware 0.2.59+). After that the busy
// sheet shows until the new state arrives, Home Assistant refuses, its "it worked" answer has stood for a moment
// without a state following (a stop on a cover that already stands still), or the wait runs out.
static constexpr uint32_t BUSY_GRACE = 400, BUSY_AFTER_ANSWER = 800, BUSY_CAP = 3000;
bool waiting(uint32_t now) const {
if (!pending || confirmed || local_feedback) return false;
return now - pending_since < (answered_at ? answered_at - pending_since + BUSY_AFTER_ANSWER : BUSY_CAP);
}
// What the busy sheet, the card's "Command sent..." and its greyed keys follow: the wait, once it takes long enough
// to be worth showing.
bool loading(uint32_t now) const { return waiting(now) && now - pending_since >= BUSY_GRACE; }
void begin(uint32_t now, bool local=false) { pending=true; pending_since=now; confirmed=false; local_feedback=local; pending_revision=revision; answered_at=0; }
void observe(uint32_t next) { revision=next; optimistic_tap=false; if (pending && revision!=pending_revision) confirmed=true; }
// Switching shows the new stand at once, as Home Assistant's own switch does (its ha-control-switch flips before the
// command goes out). `undo_optimistic` puts the old stand back when Home Assistant refuses or never answers; a state
// message always wins, because it clears the flag in `observe`.
void optimistic(bool on) { optimistic_prev_on = state == "on"; optimistic_on = on; optimistic_tap = true; state = on ? "on" : "off"; }
void undo_optimistic() { if (optimistic_tap) { state = optimistic_prev_on ? "on" : "off"; optimistic_tap = false; } }
// The attribute a small slider sets: nothing for a cover, whose position slider follows the blind as it moves.
float *slider_field() {
auto d = domain();
return d == "light" ? &brightness : d == "fan" ? &percentage : d == "media_player" ? &volume : nullptr;
}
// Holding a slider: from the send until Home Assistant reports a value within 3 % of it, reports the entity off or
// unavailable, refuses, or reports once and then stays quiet for SLIDER_SETTLE (a fan that only knows 33/66/100
// took the nearest step). A hold never outlives SLIDER_HOLD_CAP, and with no report at all it ends with the wait.
static constexpr uint32_t SLIDER_HOLD_CAP = 8000, SLIDER_SETTLE = 1500;
bool slider_holding(uint32_t now) const {
if (!std::isfinite(slider_sent) || refused_at || now - slider_sent_at >= SLIDER_HOLD_CAP) return false;
if (!slider_state_at) return waiting(now) || answered_at;
return now - slider_state_at < SLIDER_SETTLE;
}
float slider_span() const { return domain() == "light" ? 255 : domain() == "media_player" ? 1 : 100; }
// The finger let go: the field shows the value sent from now on.
void hold_slider(uint32_t now, float value) {
float *field = slider_field();
if (!field) return;
slider_sent = value; slider_real = *field; slider_sent_at = now; slider_state_at = 0; *field = value;
// A slider on an off light or fan turns it on, so the tile lights up with it, as after a tap.
if (domain() != "media_player" && state == "off") optimistic(true);
}
// A state came in with `field` already parsed: keep the sent value in front while the hold goes on.
void slider_reported(uint32_t now) {
float *field = slider_field();
if (!field || !std::isfinite(slider_sent)) return;
float real = *field;
bool moved = !std::isfinite(slider_real) ? std::isfinite(real) : std::isfinite(real) && std::fabs(real - slider_real) > 0.5f * slider_span() / 100;
slider_real = real;
bool reached = std::isfinite(real) && std::fabs(real - slider_sent) <= 3 * slider_span() / 100;
bool off = !slider_active();
if (reached || off || refused_at) { slider_sent = NAN; return; }
if (moved) slider_state_at = std::max<uint32_t>(1, now);
if (!slider_holding(now)) { slider_sent = NAN; return; }
*field = slider_sent;
}
// The hold ran out without a report that ended it: what Home Assistant last said shows again.
void release_slider() {
float *field = slider_field();
if (field && std::isfinite(slider_sent)) *field = slider_real;
slider_sent = NAN;
}
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool builtin() const { return domain() == "screen"; }
// Two built-in cards, and only one of them is a clock that has to be redrawn every minute.
bool is_clock() const { return entity == "screen.clock"; }
bool is_settings() const { return entity == "screen.settings"; }
// A navigation tile (screen.page_<n>, firmware 0.2.62+) and the page it goes to, counted from one.
bool is_page() const { return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0; }
int page_target() const { return is_page() ? entity[12] - '0' : 0; }
// Slots a tile takes: one, a row of two, or the six of a page.
unsigned cells() const { return full ? SLOTS_PER_PAGE : wide ? 2u : 1u; }
// A scene, button or input button that never ran is "unknown" in Home Assistant, which still lets you press it
// (hui-button-entity-row disables only an unavailable one): its state is the moment it last ran (firmware 0.2.58+).
bool available() const {
if (builtin()) return true;
if (!received || state.empty() || state == "unavailable") return false;
auto d = domain();
return state != "unknown" || d == "scene" || d == "button" || d == "input_button";
}
bool active() const {
return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") ||
(domain() == "sun" && state == "above_horizon") || (domain() == "climate" && available() && state != "off");
}
// A slider shows the card's colour like Home Assistant's tile sliders: grey only while stateActive()
// (frontend src/common/entity/state_active.ts) calls the entity inactive, such as an off light or fan and a
// media player that is off or in standby. A number with a value is active there, and a closed cover's
// position slider keeps the cover's colour so it never looks disabled (hui-cover-position-card-feature.ts).
bool slider_active() const {
if (!available()) return false;
auto d = domain();
if (d == "media_player") return state != "off" && state != "standby";
if (d == "cover" || d == "number" || d == "input_number") return true;
return active();
}
};
// Slot position of a tile within the fixed two-column, three-row pages.
struct Placement { uint8_t page = 0, slot = 0; };
#ifdef USE_ESP32
// ESPHome's allocator prefers PSRAM and falls back to the internal heap; this wrapper only gives it the
// comparison std::vector wants.
template<class T> struct TileAllocator {
using value_type = T;
TileAllocator() = default;
template<class U> TileAllocator(const TileAllocator<U> &) {}
T *allocate(size_t n) { return esphome::RAMAllocator<T>().allocate(n); }
void deallocate(T *p, size_t n) { esphome::RAMAllocator<T>().deallocate(p, n); }
bool operator==(const TileAllocator &) const { return true; }
bool operator!=(const TileAllocator &) const { return false; }
};
using TileList = std::vector<Tile, TileAllocator<Tile>>;
#else
using TileList = std::vector<Tile>;
#endif
// Wide tiles start in the left column and take the whole row; a right-column gap before them stays
// empty. A full tile starts a page of its own; the slots it leaves behind stay empty. Returns the page
// count (at least one).
inline unsigned pack(const TileList &tiles, size_t count, std::array<Placement, MAX_TILES> &out) {
unsigned position = 0;
for (size_t i = 0; i < count && i < MAX_TILES; ++i) {
if (tiles[i].full && position % SLOTS_PER_PAGE) position += SLOTS_PER_PAGE - position % SLOTS_PER_PAGE;
else if (tiles[i].wide && position % 2 == 1) ++position;
out[i] = {static_cast<uint8_t>(position / SLOTS_PER_PAGE), static_cast<uint8_t>(position % SLOTS_PER_PAGE)};
position += tiles[i].cells();
}
unsigned pages = (position + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE;
return pages ? pages : 1;
}
// Grid position per tile: the explicit slots when the manager sent them (a wide
// card always starts in the left column), else the in-order packing. Returns the
// page count (at least one); an empty page between two used ones stays a page.
struct Model;
inline unsigned place(const Model &m, std::array<Placement, MAX_TILES> &out);
struct Model {
TileList tiles;
// Absolute grid slot per tile when the manager sent `slots` (0.2.26+): gaps stay
// empty and a tile keeps its place. Without them the tiles pack in order.
std::array<uint8_t, MAX_TILES> slots{};
bool explicit_slots = false;
// Pages the manager wants shown even when the last ones are still empty (0.2.26+).
uint8_t pages = 1;
size_t count = 0;
std::string title = "Choose tiles in HA";
bool configured = false;
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
bool moved = false;
return set_layout(entities, name, changed, {}, moved);
}
// `changed`: the tiles differ, states restart. `moved`: same tiles on other
// positions, the pages re-place without touching states or an open card.
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed,
const std::vector<uint8_t> &positions, bool &moved) {
if (entities.size() > MAX_TILES || name.size() > 96) return false;
for (size_t i = 0; i < entities.size(); ++i) {
if (!valid_entity(entities[i])) return false;
for (size_t j = 0; j < i; ++j) if (entities[i] == entities[j]) return false;
}
if (!positions.empty()) {
if (positions.size() != entities.size()) return false;
for (size_t i = 0; i < positions.size(); ++i) {
if (positions[i] >= MAX_SLOTS) return false;
for (size_t j = 0; j < i; ++j) if (positions[i] == positions[j]) return false;
}
}
changed = !configured || count != entities.size();
for (size_t i = 0; i < entities.size() && i < tiles.size(); ++i) if (tiles[i].entity != entities[i]) changed = true;
moved = !changed && (explicit_slots != !positions.empty());
for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true;
// A title-only update must not interrupt an open control card.
title = name.empty() ? "Home" : name;
if (changed) {
// Freed before the new list is made, so the heap never holds both.
tiles.clear();
tiles.shrink_to_fit();
tiles.resize(entities.size());
count = entities.size();
for (size_t i = 0; i < count; ++i) tiles[i].entity = entities[i];
}
explicit_slots = !positions.empty();
slots.fill(0);
for (size_t i = 0; i < positions.size(); ++i) slots[i] = positions[i];
configured = true;
return true;
}
bool ready() const {
if (!configured) return false;
for (size_t i = 0; i < count; ++i) if (!tiles[i].received) return false;
return true;
}
bool accepts(size_t index, const std::string &entity) const {
return configured && index < count && tiles[index].entity == entity;
}
};
inline unsigned place(const Model &m, std::array<Placement, MAX_TILES> &out) {
if (!m.explicit_slots) return pack(m.tiles, m.count, out);
unsigned last = 0;
for (size_t i = 0; i < m.count && i < MAX_TILES; ++i) {
unsigned slot = m.slots[i];
if (m.tiles[i].full) slot -= slot % SLOTS_PER_PAGE;
else if (m.tiles[i].wide) slot &= ~1u;
out[i] = {static_cast<uint8_t>(slot / SLOTS_PER_PAGE), static_cast<uint8_t>(slot % SLOTS_PER_PAGE)};
last = std::max(last, slot + m.tiles[i].cells());
}
unsigned pages = (last + SLOTS_PER_PAGE - 1) / SLOTS_PER_PAGE;
return std::max({pages, 1u, std::min<unsigned>(m.pages, MAX_PAGES)});
}
} // namespace runtime_tiles