Files
homeassistant_espscreen/components/smart_display/runtime_model.h
T
Max Gramser 9391d1b0ac Grey out what Home Assistant calls off and colour the rest like Home Assistant, so an airco that is off no longer looks on (0.2.85)
The airco tile stayed orange while the airco was off. The firmware kept its own
short list of what turns grey and gave an off climate Home Assistant's orange,
but Home Assistant has no colour for a climate that is off: its cards fall back
to the inactive grey. Other tiles had the same fault: an off fan, a closed
blind, a docked robot, a player in standby, an idle script, a paused timer and
a camera that isn't streaming all kept their colour.

Tile::active() is now a port of the frontend's stateActive() for the domains a
tile can show, and every tile that is not active is grey. An airco that is off
reads Off, with the room's temperature when the device reports one, as Home
Assistant's tile does. tile_controls::accent() follows stateColorCss(): alarm
binary sensors red, a battery green, orange or red by its charge, a running
script or timer and a streaming camera amber, the weather per condition, the
sun amber and indigo, someone in a zone blue. Scenes, selects, numbers and
sensors keep their own colour per kind. A full-page tile lights up only for
tiles that can be off (Tile::lights_up), and a closed blind's slider keeps its
colour. The top bar's alarm classes are Home Assistant's eleven.

Checked with tools/check.sh (471 Python tests, 19/19 C++ with 58 state cases
and every colour rule) and compiled for both boards: CYD 1,677,488 B (91.4 %,
+576 B), Guition 2,054,640 B (25.3 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:31:10 +02:00

472 lines
28 KiB
C++

#pragma once
#include <algorithm>
#include <array>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <cmath>
#include <cstdint>
#include <cstdlib>
// 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 that list, the fixed part of every
// tile, in PSRAM; without PSRAM it takes the internal heap. What a tile holds beyond it, its strings and its
// Extra, comes from plain malloc and new, which ESPHome's psram setup (CONFIG_SPIRAM_USE_CAPS_ALLOC) keeps in
// the internal heap on both boards. 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"
#include "esphome/core/log.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;
// One bit per tile for the cards the next render draws again (firmware 0.2.65+). Firmware 0.2.62-0.2.64 kept 32 bits
// while a screen holds 48 tiles, so a state for tile 33 to 48 redrew the whole page. 0 beyond them: draw everything.
static_assert(MAX_TILES <= 64, "one dirty bit per tile");
constexpr uint64_t tile_bit(size_t index) { return index < 64 ? uint64_t{1} << index : 0; }
// A navigation tile (screen.page_<n>, firmware 0.2.62+).
inline bool page_entity(const std::string &entity) { return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0; }
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. Several pages may each carry the same one (firmware 0.2.65+, Model::set_layout).
if (domain == "screen") {
if (entity == "screen.clock" || entity == "screen.settings") return true;
return page_entity(entity) && 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.
// A light's effects page (firmware 0.2.70+): a select entity of the light's device with what it is set to and how many
// options it has, and a number entity with its range; the names and icons are Home Assistant's, through the add-on.
struct OptionRow { std::string entity, name, current; uint16_t count = 0; uint32_t icon = 0; };
struct NumberRow { std::string entity, name; float value = NAN, low = 0, high = 100, step = 1; uint32_t icon = 0; };
struct Extra {
// The effect a light runs (its `effect` attribute), and the rows of its effects page.
std::string effect;
std::vector<OptionRow> option_rows;
std::vector<NumberRow> number_rows;
// 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() && effect.empty() && option_rows.empty() && number_rows.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 page_entity(entity); }
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";
}
// Home Assistant's stateActive() (frontend src/common/entity/state_active.ts), the rule its cards colour by: an
// entity it calls inactive is grey, such as an airco that is off, a closed blind, a docked robot, a player in
// standby or a paused timer (firmware 0.2.71+). Only the cases of the domains a tile can show are here. A scene,
// button or image states the moment it last ran (TIMESTAMP_STATE_DOMAINS), so only "unavailable" makes it
// inactive. A built-in card has no state and is always active.
bool active() const {
if (builtin()) return true;
if (!received || state.empty() || state == "unavailable") return false;
auto d = domain();
if (d == "scene" || d == "button" || d == "input_button" || d == "image") return true;
if (state == "unknown" || state == "off") return false;
if (d == "cover") return state != "closed";
if (d == "person") return state != "not_home";
if (d == "media_player") return state != "standby";
if (d == "vacuum") return state != "idle" && state != "docked" && state != "paused";
if (d == "timer") return state == "active";
if (d == "camera") return state == "streaming" || state == "recording";
return true;
}
// A card over the whole page lights up in its state colour while it is on (firmware 0.2.62+): only a tile that can
// be off, closed, docked or away, and only while active() calls it active (firmware 0.2.71+). Sensors, numbers,
// selects, scenes, buttons, images, the weather and the sun are always active in Home Assistant and stay plain.
bool lights_up() const {
const auto d = domain();
for (const char *on_off : {"light", "switch", "input_boolean", "climate", "fan", "cover", "media_player", "vacuum",
"script", "timer", "camera", "person", "binary_sensor"})
if (d == on_off) return active();
return false;
}
// A slider shows the card's colour like Home Assistant's tile sliders: grey only while the entity is inactive
// (active() above), 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;
return domain() == "cover" || 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 gives it the comparison
// std::vector wants. RAMAllocator answers nullptr when neither has room, and std::vector (no exceptions on the
// ESP32) would write the tiles through it: set_layout checks the room first (tile_room), and anything that still
// gets here stops with a log line instead of corrupting memory (firmware 0.2.65+).
template<class T> struct TileAllocator {
using value_type = T;
TileAllocator() = default;
template<class U> TileAllocator(const TileAllocator<U> &) {}
T *allocate(size_t n) {
T *p = esphome::RAMAllocator<T>().allocate(n);
if (!p) {
ESP_LOGE("runtime", "No memory for %u tiles (%u bytes)", static_cast<unsigned>(n), static_cast<unsigned>(n * sizeof(T)));
abort();
}
return p;
}
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
// The largest block the tile list could get: ESPHome's own answer on the ESP32 (PSRAM or the internal heap,
// whichever has the larger one). Nothing on the host, which has room; the tests put in a figure of their own.
#ifdef USE_ESP32
inline size_t largest_tile_block() { return esphome::RAMAllocator<Tile>().get_max_free_block_size(); }
inline size_t (*tile_room)() = largest_tile_block;
#else
inline size_t (*tile_room)() = nullptr;
#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;
// Why the last set_layout refused a layout, for the manager's inbox status; empty when it took the layout or the
// message itself was wrong (the status then says "invalid message", as before).
std::string refusal;
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) {
refusal.clear();
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;
// A Home Assistant entity appears once on a screen; a navigation tile may sit on several pages (firmware 0.2.65+).
// Everything that reaches a tile goes by its index (Model::accepts), so each copy gets its own options.
if (page_entity(entities[i])) continue;
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 longer list needs one block that holds every tile. Without it the layout is refused before anything changes, so
// the screen keeps the tiles it shows and the manager hears why (firmware 0.2.65+).
if (entities.size() > tiles.capacity() && tile_room && tile_room() < entities.size() * sizeof(Tile)) {
refusal = "Error: no memory for " + std::to_string(entities.size()) + " tiles";
return false;
}
// 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