Let a tap look instant and keep the spinner for a command that really takes a while (0.2.70)

Home Assistant answered every command in 342-599 ms on a real installation,
yet a tile was busy for at least a second and up to six without a confirming
state. Now nothing is drawn for the first 400 ms, a tap that switches shows
the new stand at once as Home Assistant's own switch does, and the sheet
appears only for a slow command. Home Assistant's "it worked" answer ends the
wait 800 ms later, which covers a stop on a cover that already stands still,
and a wait without any answer ends after three seconds instead of six. The
cards' keys, sliders, chips and the -/+ edit now ask for an answer too, so a
refusal shows wherever a finger presses.

Firmware 0.2.59. A 200 ms LVGL timer runs only while a command is under way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 22:26:38 +02:00
parent 45fcbdd807
commit 91a2445663
13 changed files with 236 additions and 44 deletions
+23 -4
View File
@@ -132,6 +132,8 @@ struct Tile {
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 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;
@@ -144,6 +146,8 @@ struct Tile {
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;
@@ -161,10 +165,25 @@ struct Tile {
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(uint32_t next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
// 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; } }
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.
+57 -18
View File
@@ -184,7 +184,7 @@ struct Widgets {
lv_obj_t *pill{}, *pill_value{}, *knob{}, *control_slider{}; int knob_on=-1;
lv_color_t panel_accent{}, panel_text{};
// Busy sheet: a translucent white cover with a small spinner while a command is under way.
lv_obj_t *busy{}, *spinner{};
lv_obj_t *busy{}, *spinner{}; bool busy_drawn=false;
// Page fill skeleton: a sheet in the card's colour over its contents until the card is drawn.
lv_obj_t *veil{};
};
@@ -573,7 +573,7 @@ inline std::string receive(const std::string &payload) {
// A chip just tapped keeps its choice while Home Assistant is still busy with it, so another update
// of the robot (its battery, say) does not flip the row back for a moment.
std::vector<std::pair<char, std::string>> tapped;
if (tile.loading(esphome::millis())) for (auto &c : tile.extra().choices) if (!c.sent.empty()) tapped.emplace_back(c.kind, c.sent);
if (tile.waiting(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;
@@ -607,6 +607,31 @@ inline std::string receive(const std::string &payload) {
});
return result;
}
// Between the manager's messages nothing redraws a card, so a command under way gets its own 200 ms LVGL timer: it
// brings the busy sheet up once the grace has passed, takes it away as soon as Home Assistant answered or the wait ran
// out, and deletes itself when no tile waits any more (firmware 0.2.59+).
inline lv_timer_t *busy_timer{};
inline void end_wait(size_t index) {
auto &t = model.tiles[index];
t.pending = false;
t.undo_optimistic();
if (auto *x = t.extra_ptr()) for (auto &c : x->choices) c.sent.clear();
}
inline void busy_watch(lv_timer_t *) {
const uint32_t now = esphome::millis();
bool any = false;
for (auto &w : widgets) {
if (!w.tile || w.index >= model.count) continue;
auto &t = model.tiles[w.index];
if (!t.pending) continue;
if (!t.waiting(now) && !t.confirmed) end_wait(w.index);
any = any || t.pending;
if (t.loading(now) != w.busy_drawn) refresh_tile(w.index);
}
if (!any && busy_timer) { lv_timer_delete(busy_timer); busy_timer = nullptr; }
}
inline void watch_busy() { if (!busy_timer) busy_timer = lv_timer_create(busy_watch, 200, nullptr); }
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
// Home Assistant answers an action sent with a call id (firmware 0.2.58+, Home Assistant 2025.10+). ESPHome keeps each
// answer's callback until the answer arrives and has no timeout of its own, so a tap asks for at most four answers at a
@@ -624,11 +649,20 @@ inline void watch_call(esphome::api::HomeassistantActionRequest &request, const
request.call_id = id;
esphome::api::global_api_server->register_action_response_callback(id, [id, entity](const esphome::api::ActionResponse &answer) {
for (auto &c : watched_calls) if (c.id == id) c = {};
if (answer.is_success() || answer.get_error_message().c_str() == NO_ANSWER) return;
if (answer.is_success() || answer.get_error_message().c_str() == NO_ANSWER) {
// "It worked" without a new state (a stop on a cover that already stands still) ends the wait in a moment
// instead of running to the cap.
if (answer.is_success())
for (size_t i = 0; i < model.tiles.size(); ++i)
if (model.tiles[i].entity == entity && model.tiles[i].pending && !model.tiles[i].answered_at)
model.tiles[i].answered_at = std::max<uint32_t>(1, esphome::millis());
return;
}
ESP_LOGW("runtime_action", "Home Assistant refused the action for %s: %.*s", entity.c_str(),
(int) answer.get_error_message().size(), answer.get_error_message().c_str());
for (size_t i = 0; i < model.tiles.size(); ++i) if (model.tiles[i].entity == entity) {
model.tiles[i].pending = false;
model.tiles[i].undo_optimistic();
model.tiles[i].refused_at = std::max<uint32_t>(1, esphome::millis());
refresh_tile(i);
}
@@ -646,12 +680,13 @@ inline void expire_calls(uint32_t now) {
// Marks every tile of the entity busy and sends; `watch` asks Home Assistant for an answer (a tap).
inline void send_action(esphome::api::HomeassistantActionRequest &request, const std::string &entity, bool watch) {
for(size_t i=0;i<model.tiles.size();++i) if(model.tiles[i].entity==entity){model.tiles[i].begin(esphome::millis());model.tiles[i].refused_at=0;refresh_tile(i);}
watch_busy();
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
if (watch) watch_call(request, entity);
#endif
esphome::api::global_api_server->send_homeassistant_action(request);
}
inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="", bool watch=false) {
inline void action(const std::string &service, const std::string &entity, const std::string &key="", const std::string &value="", bool watch=true) {
if (!fresh() || !valid_entity(entity)) return;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service);
@@ -774,7 +809,7 @@ inline bool slider_changed=false;
inline int slider_held=0;
inline void slider_event(lv_event_t *e);
inline void commit_slider(unsigned i,int raw){
if(i>=model.count || !fresh())return;auto &t=model.tiles[i];if(!t.available() || t.loading(esphome::millis()))return;
if(i>=model.count || !fresh())return;auto &t=model.tiles[i];if(!t.available() || t.waiting(esphome::millis()))return;
float value=std::clamp(raw,0,1000)/1000.0f;auto d=t.domain();
// A light's slider stops at 1 %, as in Home Assistant; tapping the card turns it off.
if(d=="light")action("light.turn_on",t.entity,"brightness",std::to_string(std::max(3,(int)std::lround(value*255))));
@@ -837,7 +872,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
return;
}
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
// Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
static const std::pair<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
@@ -1089,7 +1124,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
bool cleaning=t.state=="cleaning",paused=t.state=="paused";
bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning";
int inner=width-2*pad,gap=large?12:6,radius=lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN);
std::string state=t.awaiting_action(now)&&!t.confirmed?"Command sent...":detail_state(t);
std::string state=t.loading(now)?"Command sent...":detail_state(t);
// A robot with little to set gets a hero on the small screen too; one with mode and water rows uses a status row.
bool small_hero=!large && !mode && !t.choice('w');
int y=large?92:52;
@@ -1634,12 +1669,12 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
lv_obj_add_event_cb(detail_switch,[](lv_event_t *e){
if(detail_index>=model.count)return;
auto &tile=model.tiles[detail_index];auto *control=lv_event_get_target_obj(e);
bool allowed=fresh() && tile.available() && !tile.awaiting_action(esphome::millis()) &&
bool allowed=fresh() && tile.available() && !tile.waiting(esphome::millis()) &&
cyd::touch_guard.accept(esphome::millis(),350);
bool requested_on=lv_obj_has_state(control,LV_STATE_CHECKED);
// Only HA's reported state is authoritative, including a refused/failed command.
if(tile.state=="on")lv_obj_add_state(control,LV_STATE_CHECKED);else lv_obj_remove_state(control,LV_STATE_CHECKED);
if(allowed)action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);
if(allowed){tile.optimistic(requested_on);action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);}
},LV_EVENT_VALUE_CHANGED,nullptr);
if(detail_action_count<32)detail_actions[detail_action_count++]=detail_switch;
right-=sw+(large?14:8);
@@ -1857,13 +1892,16 @@ inline void event(lv_event_t *event) {
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
if (!tile.available() || tile.waiting(esphome::millis()) || tile.tap=="none") return;
// A camera or an image entity opens full screen on a board that draws images (firmware 0.2.57+).
if(d=="camera" || d=="image"){if(camera_supported())camera_open(tile.entity,tile.name);return;}
// tile_controls::tap_route decides; tests/test_tile_controls.cpp keeps every older tap choice routed as before.
auto tap = tile_controls::tap_route(tile, code == LV_EVENT_LONG_PRESSED);
switch (tap.route) {
case tile_controls::TapRoute::ACTION:
// On / off shows the new stand at once, as Home Assistant's switch does; other actions have nothing to show yet.
if (tap.service == d + ".toggle" && (d == "light" || d == "switch" || d == "input_boolean" || d == "fan"))
tile.optimistic(tile.state != "on");
action(tap.service, tile.entity, "", "", true);
return;
case tile_controls::TapRoute::CUSTOM:
@@ -2457,8 +2495,8 @@ inline void control_event(lv_event_t *e) {
if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":t.unit.empty()?"":" "+t.unit;label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));}
return;
}
if(t.awaiting_action(now))return;
if(command==tile_controls::TOGGLE)t.optimistic_on=t.state!="on";
if(t.waiting(now))return;
if(command==tile_controls::TOGGLE)t.optimistic(t.state!="on");
auto a=tile_controls::key_action(t,command,w.key_args[n]);
if(a.valid())action(a.service,t.entity,a.key,a.value);
}
@@ -2543,7 +2581,8 @@ inline void render_slot(size_t slot) {
lv_obj_set_height(w.value,value_height);
int content_w=content_width(w),content_h=content_height(w);
lap(swipe_profile::LAYOUT);
set_busy(w,pending && !t.builtin(),large_tile);
w.busy_drawn = pending && !t.builtin();
set_busy(w,w.busy_drawn,large_tile);
lap(swipe_profile::BUSY);
for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom);
if(custom){
@@ -3123,7 +3162,7 @@ inline int last_clock_minute=-2;
inline bool was_fresh=false;
inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
auto &t=model.tiles[detail_index];bool waiting=t.loading(esphome::millis());
// The history the card waits for: drawn once it is here (a finger on the screen holds that back), asked for
// again every 30 s, and after 8 s without it (an app from before 0.2.59, Home Assistant away) the card says so.
if(history_chart.status&&!history_chart.ready){
@@ -3159,10 +3198,10 @@ inline void tick() {
for(size_t i=0;i<model.tiles.size();++i){
auto &t=model.tiles[i];
if(t.refused_at && esphome::millis()-t.refused_at>=4000){t.refused_at=0;card(i);}
if(!t.pending || t.loading(esphome::millis()))continue;
t.pending=false;card(i);
if(!t.pending || t.waiting(esphome::millis()))continue;
// A vacuum chip Home Assistant never confirmed goes back to what the robot reports.
bool sent=false;if(auto *x=t.extra_ptr())for(auto &c:x->choices){sent=sent||!c.sent.empty();c.sent.clear();}
bool sent=false;if(auto *x=t.extra_ptr())for(auto &c:x->choices)sent=sent||!c.sent.empty();
end_wait(i);card(i);
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.
@@ -3170,7 +3209,7 @@ inline void tick() {
auto &t=model.tiles[i];if(!std::isfinite(t.edit_value))continue;
uint32_t now=esphome::millis();
if(!t.edit_sent){
if(now-t.edit_since<700 || t.awaiting_action(now))continue;
if(now-t.edit_since<700 || t.waiting(now))continue;
auto a=tile_controls::edit_action(t,t.edit_value);
if(a.valid()){t.edit_sent=true;t.edit_since=now;action(a.service,t.entity,a.key,a.value);}else t.edit_value=NAN;
}else if(now-t.edit_since>10000){t.edit_value=NAN;card(i);}
+1 -1
View File
@@ -308,7 +308,7 @@ inline Action key_action(const Tile &t, int command, const std::string &arg = ""
// ---- Vacuum card rows (firmware 0.2.39+) ----
// The value a row shows as chosen: the one just tapped while Home Assistant has not answered yet.
inline const std::string &shown_value(const Tile &t, const runtime_tiles::Choice &c, uint32_t now) {
return !c.sent.empty() && t.loading(now) ? c.sent : c.current;
return !c.sent.empty() && t.waiting(now) ? c.sent : c.current;
}
// Role of the cleaning mode in use: 'v' vacuum only, 'm' mop only, 'b' both, 'a' automatic; 'b' when
// the robot has no mode select or reports a mode the manager did not know.
+24
View File
@@ -176,6 +176,30 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.70 / firmware 0.2.59
Firmware only (`runtime_model.h`, `runtime_tiles.h`, `tile_controls.h`); the app raises `FIRMWARE_VERSION`. No
protocol, storage or editor change, and an older app drives this firmware as before.
- `Tile::waiting(now)` replaces `awaiting_action`: pending, not confirmed, not `local_feedback`, and within `BUSY_CAP`
(3000 ms), or within `BUSY_AFTER_ANSWER` (800 ms) of Home Assistant's "it worked" answer (`answered_at`, set by the
action-response callback). It guards what a finger may do: a second tap, a slider, a key, a chip, the -/+ edit.
- `Tile::loading(now)` is what gets drawn: `waiting` once `BUSY_GRACE` (400 ms) has passed. Home Assistant answered
every command in 342-599 ms on Max's installation, so a command that lands shows nothing at all; the busy sheet, the
card's "Command sent..." and its greyed keys only appear for a slow one. The old rule (always busy for 1000 ms,
150 ms for a switch, then up to 6000 ms without confirmation) is gone, and with it the switch's own window.
- `Tile::optimistic(on)` writes the new stand into `state` and keeps the old one in `optimistic_prev_on`, as Home
Assistant's `ha-control-switch` flips before the command goes out. A tap that sends `<domain>.toggle` on a light,
switch, input boolean or fan uses it, and so does a card's switch. `observe` clears the flag, so a state message
always wins; `undo_optimistic` puts the old stand back on a refusal or when the wait runs out (`end_wait`, which
also drops a chip's sent value).
- Between the manager's messages nothing redraws a card, so `watch_busy` creates one 200 ms LVGL timer while any tile
waits: it brings the sheet up after the grace, ends waits that ran out, and deletes itself. `Widgets::busy_drawn`
keeps a card from being drawn again for nothing.
- `action()` asks for an answer by default now, so the cards' keys, sliders, chips and the -/+ edit get the same
refusal and "it worked" handling a tap has had since 0.2.67. At most four answers are watched at a time, as before.
- Firmware before 0.2.59 keeps its own timing; the app sends nothing new, so any app drives either firmware.
### Compatibility 0.2.67 / firmware 0.2.58
App (new `ha_catalogue.py`, `core.py`, `server.py`, `header_bar.py`, `history_card.py`, `tile_icons.py`, the editor and
+71
View File
@@ -0,0 +1,71 @@
# Test results app 0.2.70 / firmware 0.2.59 (2026-09-17)
Max, after the 0.2.67 update round on his own screens: "ik vind sws alle busy heel lang duren" - every busy state feels
longer than it needs to be. This release makes a tap look instant and keeps the spinner for commands that really take a
while. See CHANGELOG 0.2.70 and the compatibility note in docs/RELEASING.md. Built on 0.2.69 (`45fcbdd`).
## What the old rule did, measured
- `Tile::loading()` held a tile busy for at least 1000 ms (150 ms for a switch) and then, without a confirming state,
up to 6000 ms. The 1000 ms came from the first runtime tiles; nothing measured it.
- The bench screens' own logs during the 0.2.67 hardware round, against Max's Home Assistant: a tap was answered with a
new state in 342, 399, 404, 417, 434, 467, 550 and 599 ms. Eight of eight under 600 ms, so the sheet always outlived
the command.
- `cover.stop_cover` on a cover that already stands still brings no new state at all: Max pressed the middle key in the
cover card and the sheet stayed the full six seconds. The log shows the command going out at 21:43:31 with no state
after it.
- Home Assistant itself shows no spinner: `ha-control-switch._toggle()` flips the switch before the command goes out
(180 ms CSS transition) and the state that follows confirms it.
## Automated
- Python (`.venv-portal`): 386 tests OK, the same set as 0.2.69 (this release is firmware only; the app just raises
`FIRMWARE_VERSION`).
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 17/17 PASS. `tests/test_action_feedback.cpp` now holds the new
rules: nothing drawn in the first 400 ms while a second finger is still ignored, the sheet from 400 ms, the cap at
3000 ms, an "it worked" answer ending the wait 800 ms later, a tap that opens a card drawing no sheet at all, the
clock wrapping around, a switch confirming at 250 ms without ever drawing a sheet, and the optimistic stand with its
undo and a state message overruling it.
- `generate_packages.py --check` and `generate_icons.py --check` green.
## Firmware on the Mac (host builds, driven over the ESPHome API)
`tap_host.py`, now with the busy sheet in its probe (`busy=`) and the probed slot in every line, so the host's
one-to-two-second log lag cannot be read as another card's answer. **Guition 34/34, CYD 34/34.** The eight new checks:
| Check | Guition | CYD |
|---|---|---|
| A tap on a lamp shows the new stand at once | pass | pass |
| and draws no sheet yet | pass | pass |
| The sheet comes when Home Assistant takes longer | pass | pass |
| An answer that brings no new state lets go in a moment | pass | pass |
| and the tile shows Home Assistant's own stand again | pass | pass |
| A command without an answer keeps the sheet | pass | pass |
| and lets go after three seconds | pass | pass |
| A refused switch says Refused, then the old stand is back | pass | pass |
The 26 checks of 0.2.67 (On / off, Perform action with its data, a refusal, holding, an unanswered call, Home
Assistant's words) pass unchanged.
## Max's own screen
The Guition at his desk got firmware 0.2.59 over the air from this tree (`esphome upload`, own name, keys and Wi-Fi;
Home Assistant kept the device and the app reported Synced again within half a minute). His verdict after tapping:
"wauw dit is perfect, werkt veel fijner."
## Builds (ESPHome 2026.6.2)
Check profiles with the generated packages and local components; no errors or compiler warnings.
| Profile | RAM | Flash | vs 0.2.69 |
|---|---|---|---|
| CYD | 23.9% (78,396 B) | 79.2% (1,453,351 B) | +112 B RAM, +1,060 B flash |
| Guition | 25.6% (83,972 B) | 21.0% (1,708,627 B) | +112 B RAM, +1,024 B flash |
The RAM is the answer timestamp and the two optimistic flags per tile, plus one LVGL timer while a command is under way.
## Not tested
- The CYD on real hardware: it kept firmware 0.2.58 until the release, so its Update round follows the push.
- A refusal on Max's own screen: it needs a tile whose action Home Assistant rejects, which the 0.2.67 round already
showed there ("value must be at most 100 at 'brightness_pct'").
+1 -1
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.58"
SCREEN_FIRMWARE_VERSION: "0.2.59"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
+1 -1
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.58"
SCREEN_FIRMWARE_VERSION: "0.2.59"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+1 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.58"
SCREEN_FIRMWARE_VERSION: "0.2.59"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
+1 -1
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.58"
SCREEN_FIRMWARE_VERSION: "0.2.59"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
+11
View File
@@ -1,3 +1,14 @@
## 0.2.70 (firmware 0.2.59)
A tap looks instant, and the spinner only shows up when something really takes a while.
- **A switch flips at once.** Tap a light, switch, helper or fan and the tile shows the new stand right away, as Home Assistant's own switch does, instead of a spinner over the tile. Home Assistant's own state confirms it a moment later. Refuses it, then the tile says Refused and the old stand comes back.
- **No spinner for a normal command.** Home Assistant answers in about half a second, and for that long the tile now shows nothing at all. Takes it longer, then the spinner appears as before. On Max's house every command measured was answered in 342 to 599 ms, and every one of those showed a spinner for a full second before.
- **A command that changes nothing lets go.** Press Stop on a cover that already stands still and Home Assistant reports nothing new. The screen used to wait six seconds; it now lets go a moment after Home Assistant says the command went through.
- **Nothing waits longer than three seconds.** A screen that gets no answer at all, because Home Assistant is older or may not perform actions, gives up after three seconds instead of six.
- **The cards, keys, chips and sliders follow the same rule**, and they all ask Home Assistant for an answer now, so a refusal shows up wherever you press.
- Needs firmware 0.2.59: press **Update** on the screen.
## 0.2.69 (firmware 0.2.58)
A very large sensor value no longer stops a screen's updates.
+1 -1
View File
@@ -20,7 +20,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.58'
FIRMWARE_VERSION = '0.2.59'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.69"
version: "0.2.70"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+43 -15
View File
@@ -11,30 +11,58 @@ int main() {
// A tile keeps the fingerprint of its last state message, not the message itself.
auto rev=[](const char *state){ return runtime_tiles::state_revision(state,""); };
runtime_tiles::Tile t;
t.entity="light.lamp";
t.revision=rev("off"); t.begin(100);
assert(t.loading(200)); assert(t.awaiting_action(200));
// Nothing is drawn in the first 400 ms, so a command Home Assistant confirms in 350 ms looks instant. A second
// finger is ignored from the first moment all the same.
assert(t.waiting(200)); assert(!t.loading(200));
assert(t.loading(500));
t.observe(rev("off")); assert(!t.confirmed);
t.observe(rev("on")); assert(t.confirmed);
assert(t.loading(999)); assert(!t.loading(1100));
t.begin(2000); t.observe(rev("on"));
assert(t.loading(7999)); assert(!t.loading(8000)); assert(!t.awaiting_action(8000));
t.begin(0xFFFFFFF0); assert(t.loading(20));
t.begin(100,true);
assert(t.loading(1099)); assert(!t.awaiting_action(1099)); assert(!t.loading(1100));
assert(!t.waiting(500)); assert(!t.loading(500));
// A plain switch changes state with identical attributes. This must confirm
// immediately instead of leaving the tile blocked for the six-second timeout.
// Without a state the wait runs to three seconds, not six.
t.begin(2000); t.observe(rev("on"));
assert(t.waiting(4999)); assert(t.loading(2400)); assert(!t.loading(2399));
assert(!t.waiting(5000)); assert(!t.loading(5000));
// "It worked" without a new state (a stop on a cover that already stands still) ends the wait 800 ms later.
t.begin(2000); t.observe(rev("on")); t.answered_at=2600;
assert(t.waiting(3399)); assert(!t.waiting(3400)); assert(!t.loading(3400));
t.begin(2000); assert(t.answered_at==0);
// A tap that opens a card draws no sheet at all, and the clock wrapping around changes nothing.
t.begin(100,true);
assert(!t.waiting(200)); assert(!t.loading(1099));
t.begin(0xFFFFFFF0); t.observe(rev("on"));
assert(t.waiting(20)); assert(!t.loading(20)); assert(t.loading(0xFFFFFFF0+400));
// A switch changes state with identical attributes. That still confirms at once, and within the first 400 ms
// nothing was drawn anyway.
runtime_tiles::Tile sw;
sw.entity="switch.printer";
sw.revision=runtime_tiles::state_revision("off","{}"); sw.begin(100);
assert(sw.loading(249));
assert(!sw.loading(249));
sw.observe(runtime_tiles::state_revision("on","{}"));
assert(!sw.loading(250));
assert(!sw.waiting(250)); assert(!sw.loading(500));
sw.begin(300); sw.observe(runtime_tiles::state_revision("on","{}"));
assert(sw.loading(1000)); // Repeated identical data is not confirmation.
assert(!sw.loading(6300));
sw.entity="input_boolean.test"; sw.begin(0xFFFFFFF0); sw.observe(rev("off"));
assert(sw.loading(20)); assert(!sw.loading(200));
assert(sw.waiting(1000)); // Repeated identical data is not confirmation.
assert(!sw.waiting(3300));
// On / off shows the new stand at once, as Home Assistant's own switch does.
runtime_tiles::Tile lamp;
lamp.entity="light.lamp"; lamp.state="off"; lamp.revision=rev("off");
lamp.optimistic(true); lamp.begin(100);
assert(lamp.state=="on"); assert(lamp.optimistic_tap); assert(lamp.optimistic_on);
// Home Assistant refuses, or never answers: the old stand comes back.
lamp.undo_optimistic();
assert(lamp.state=="off"); assert(!lamp.optimistic_tap);
lamp.undo_optimistic(); assert(lamp.state=="off");
// A state message always wins: it clears the flag, so a later wait that runs out changes nothing.
lamp.optimistic(true); lamp.begin(200);
lamp.state="off"; lamp.observe(rev("off"));
assert(!lamp.optimistic_tap);
lamp.undo_optimistic(); assert(lamp.state=="off");
// The fingerprint follows state and attributes, and the streaming writer (what receive() hands to
// ArduinoJson) gives the same value as hashing the joined text.