Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,9 +29,11 @@ class TouchGuard {
|
||||
public:
|
||||
enum Reject { NONE, MOVED, TOO_SHORT, USED, BOUNCE };
|
||||
// Board tuning, set from the profile on boot: how far the finger may drift (pixels) before
|
||||
// a tap is dropped, 0 for no limit at all (LVGL then decides: releasing inside the tile is a
|
||||
// tap, leaving it is not), and the shortest contact that counts. A resistive panel (CYD)
|
||||
// bounces on landing and lift-off and needs both; a capacitive one (Guition) needs neither.
|
||||
// a tap is dropped, 0 for no limit at all, and the shortest contact that counts. A resistive
|
||||
// panel (CYD) bounces on landing and lift-off and needs both; a capacitive one (Guition) needs
|
||||
// neither. Without a limit a tile checks itself that the finger let go on it (runtime_tiles::event:
|
||||
// LVGL keeps the press on the tile and clicks it wherever the finger lets go), and a quick flick
|
||||
// is LVGL's gesture, which consume()s the contact (firmware 0.2.65+).
|
||||
void configure(int move_limit_px, uint32_t min_press_ms) {
|
||||
move_limit_ = move_limit_px;
|
||||
min_press_ = min_press_ms;
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
#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 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.
|
||||
// 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 {
|
||||
@@ -25,6 +29,12 @@ constexpr size_t SLOTS_PER_PAGE = 6;
|
||||
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('.');
|
||||
@@ -34,10 +44,10 @@ inline bool valid_entity(const std::string &entity) {
|
||||
!(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.
|
||||
// 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 entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0 && entity[12] >= '1' && entity[12] <= static_cast<char>('0' + MAX_PAGES);
|
||||
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;
|
||||
@@ -260,7 +270,7 @@ struct Tile {
|
||||
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; }
|
||||
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; }
|
||||
@@ -291,13 +301,22 @@ struct Tile {
|
||||
// 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.
|
||||
// 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) { return esphome::RAMAllocator<T>().allocate(n); }
|
||||
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; }
|
||||
@@ -306,6 +325,14 @@ 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).
|
||||
@@ -336,6 +363,9 @@ struct Model {
|
||||
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);
|
||||
@@ -344,9 +374,13 @@ struct Model {
|
||||
// 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()) {
|
||||
@@ -360,6 +394,12 @@ struct Model {
|
||||
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) {
|
||||
|
||||
@@ -301,7 +301,13 @@ inline std::string receive(const std::string &payload) {
|
||||
bool changed = false, moved = false, was_configured = model.configured;
|
||||
const std::string previous_title = model.title;
|
||||
const uint8_t previous_pages = model.pages;
|
||||
if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) return false;
|
||||
if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) {
|
||||
if (!model.refusal.empty()) {
|
||||
ESP_LOGW("runtime", "Layout of %u tiles refused: %s", static_cast<unsigned>(entities.size()), model.refusal.c_str());
|
||||
result = model.refusal;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Empty pages the user keeps on purpose; absent on older managers.
|
||||
model.pages = root["pages"].is<unsigned>() ? static_cast<uint8_t>(std::clamp<unsigned>(root["pages"].as<unsigned>(), 1, MAX_PAGES)) : 1;
|
||||
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
|
||||
@@ -2084,11 +2090,29 @@ inline std::string date_text(const esphome::ESPTime &now) {
|
||||
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
|
||||
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
|
||||
}
|
||||
// Where the finger is, or where it let go, in the screen's coordinates; false without a finger (a test's event).
|
||||
inline bool finger_at(lv_point_t &point) {
|
||||
auto *indev = lv_indev_active();
|
||||
if (!indev || lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) return false;
|
||||
lv_indev_get_point(indev, &point);
|
||||
return true;
|
||||
}
|
||||
inline void event(lv_event_t *event) {
|
||||
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
|
||||
if (!enabled || w.index >= model.count) return;
|
||||
auto code = lv_event_get_code(event);
|
||||
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
|
||||
// A finger that slid off the card is not a tap or a hold on it (the Guition, firmware 0.2.65+). LVGL keeps the press
|
||||
// on the object it started on (LV_OBJ_FLAG_PRESS_LOCK, which LVGL 9.5 sets on every child) and clicks it on release
|
||||
// wherever the finger is. The CYD's drift limit already drops such a tap; the Guition has none (TOUCH_MOVE_LIMIT_PX 0,
|
||||
// so a firm press that drifts still counts) and asks where the finger let go instead. The lock itself stays: without
|
||||
// it a finger that slides on to a slider would press and drag that slider.
|
||||
lv_point_t point;
|
||||
if (cyd::touch_guard.move_limit() <= 0 && finger_at(point) && !lv_obj_hit_test(w.tile, &point)) {
|
||||
ESP_LOGI("touch", "tap on %s ignored: %s outside the tile", model.tiles[w.index].entity.c_str(),
|
||||
code == LV_EVENT_LONG_PRESSED ? "held" : "let go");
|
||||
return;
|
||||
}
|
||||
// The built-in cards need nothing from Home Assistant: the settings card opens the screen's own page with
|
||||
// the link down too, as its card says (firmware 0.2.49+), and the clock card does nothing under a finger.
|
||||
if (model.tiles[w.index].builtin()) {
|
||||
@@ -2137,6 +2161,22 @@ inline void event(lv_event_t *event) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The push that wakes a screen in standby only wakes it (dim_wake_overlay), with one exception (firmware 0.2.65+): on a
|
||||
// page that is one full tile which switches something on or off, the push also switches it, as a light switch on the
|
||||
// wall does in the dark. It is handed to the tile as its own tap, so it goes the way a tap on a lit screen goes (the
|
||||
// touch guard, the busy sheet, the new stand at once) and counts once. A push on the tile's slider or keys, the top bar
|
||||
// or the page bar only wakes, as does a flick. Called by both boards' dim_wake_overlay; true when it tapped the tile.
|
||||
inline bool wake_tap() {
|
||||
auto &w = widgets[0];
|
||||
if (!enabled || !fresh() || !w.tile || !w.full || w.index >= model.count || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN)) return false;
|
||||
const auto &t = model.tiles[w.index];
|
||||
const auto route = tile_controls::tap_route(t, false);
|
||||
if (t.builtin() || route.route != tile_controls::TapRoute::ACTION || route.service != t.domain() + ".toggle") return false;
|
||||
lv_point_t point;
|
||||
if (!finger_at(point) || lv_indev_search_obj(w.tile, &point) != w.tile) return false;
|
||||
lv_obj_send_event(w.tile, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
return true;
|
||||
}
|
||||
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
|
||||
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
|
||||
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector) {
|
||||
@@ -3150,9 +3190,10 @@ inline void render_slot(size_t slot) {
|
||||
// What the next render() draws besides the name and the top bar: the cards of the tiles a state
|
||||
// message or a tick named, or every card. A refresh that names nothing (the board's own triggers,
|
||||
// such as the minute tick and time sync) draws every card.
|
||||
inline uint32_t dirty_tiles=0;
|
||||
inline uint64_t dirty_tiles=0;
|
||||
inline bool dirty_all=false, dirty_header=false;
|
||||
inline void refresh_tile(size_t index) { if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; if(refresh)refresh(); }
|
||||
inline void mark_tile(size_t index) { if(uint64_t bit=tile_bit(index))dirty_tiles|=bit; else dirty_all=true; }
|
||||
inline void refresh_tile(size_t index) { mark_tile(index); if(refresh)refresh(); }
|
||||
inline void refresh_header_only() { dirty_header=true; if(refresh)refresh(); }
|
||||
inline void refresh_all() { dirty_all=true; if(refresh)refresh(); }
|
||||
// Slots whose new page content is still to come: the page fill draws them, render() leaves them.
|
||||
@@ -3173,12 +3214,12 @@ inline void render(lv_obj_t *room) {
|
||||
render_header();
|
||||
lap(swipe_profile::HEADER);
|
||||
bool all=dirty_all || (!dirty_tiles && !dirty_header);
|
||||
uint32_t tiles=dirty_tiles;
|
||||
uint64_t tiles=dirty_tiles;
|
||||
dirty_all=dirty_header=false;dirty_tiles=0;
|
||||
for (size_t slot = 0; slot < SLOTS_PER_PAGE; ++slot) {
|
||||
const auto &w=widgets[slot];
|
||||
if(slot_pending[slot] || w.index>=model.count)continue;
|
||||
if(all || (w.index<32 && (tiles>>w.index)&1))render_slot(slot);
|
||||
if(all || (tiles & tile_bit(w.index)))render_slot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3664,7 +3705,7 @@ inline void tick() {
|
||||
if(!enabled)return;
|
||||
// Only the cards that change are drawn again: a clock or a finished command redraws its own card.
|
||||
bool redraw=false;
|
||||
auto card=[&](size_t index){ if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; redraw=true; };
|
||||
auto card=[&](size_t index){ mark_tile(index); redraw=true; };
|
||||
// HA dropping or returning and the feed timing out change every card at once.
|
||||
bool now_fresh=fresh();
|
||||
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
|
||||
@@ -3712,7 +3753,10 @@ inline void tick() {
|
||||
// The second hand moves on its own: only its line is redrawn, and it hides during standby. Only while the
|
||||
// slot's parts are a dial: right after a page switch the slot already names the clock while its parts still
|
||||
// belong to the card drawn before (a forecast's hour labels take part 18 too), until the fill draws the dial.
|
||||
else if(w.parts[18] && w.points && w.extra_mode=="analog" && t.is_clock() && t.display=="analog")second_hand(w,now);
|
||||
// A single dial is drawn as "calendar", a wide or full one as "analog"; firmware 0.2.62-0.2.64 moved only the
|
||||
// latter, so the hand of a single clock stood still (0.2.65). Part 18 must be the hand's own line either way.
|
||||
else if(w.parts[18] && lv_obj_check_type(w.parts[18],&lv_line_class) && w.points &&
|
||||
(w.extra_mode=="analog" || w.extra_mode=="calendar") && t.is_clock() && t.display=="analog")second_hand(w,now);
|
||||
}
|
||||
}
|
||||
if(redraw && refresh)refresh();
|
||||
|
||||
@@ -664,7 +664,9 @@ inline void draw() {
|
||||
side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
|
||||
lv_obj_set_width(label, half - 12);
|
||||
lv_obj_set_pos(label, 6, (m.pager - label_h) / 2);
|
||||
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_SHORT_CLICKED,
|
||||
// CLICKED, not SHORT_CLICKED: LVGL sends no short click after a press of long_press_time (400 ms), so a firm or
|
||||
// slow press did nothing (firmware 0.2.65+). The bar has no hold of its own.
|
||||
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_CLICKED,
|
||||
(void *) (intptr_t) (side ? per_page : -per_page));
|
||||
}
|
||||
auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, theme::MUTED,
|
||||
|
||||
Reference in New Issue
Block a user