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.