Keep a small slider where the finger left it while the light fades towards it (0.2.71)

A light group's lamps fade, Home Assistant reports every step, and the tile's slider followed each one: after a drag it
jumped back to the old value and crept up. The Tile now holds the sent value (brightness, fan percent, volume) until
Home Assistant reports within 3 % of it, reports the entity off, refuses, or reports once and stays quiet for 1.5 s;
never longer than 8 s, and without any report the hold ends with the wait. A cover's position slider keeps following
the blind. Firmware 0.2.60; host checks 17/17 on both boards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 23:10:00 +02:00
parent 91a2445663
commit 2cb347964d
12 changed files with 201 additions and 9 deletions
+48
View File
@@ -132,6 +132,11 @@ 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 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.
@@ -184,6 +189,49 @@ struct Tile {
// 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.
+8 -3
View File
@@ -536,6 +536,7 @@ inline std::string receive(const std::string &payload) {
tile.unit = string(a["unit_of_measurement"], 20);
tile.brightness = number(a["brightness"]);
tile.percentage = number(a["percentage"]);
tile.slider_reported(esphome::millis());
tile.position = number(a["current_position"]);
next.tilt = number(a["current_tilt_position"]);
tile.current = number(a["current_temperature"]);
@@ -663,6 +664,7 @@ inline void watch_call(esphome::api::HomeassistantActionRequest &request, const
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].release_slider();
model.tiles[i].refused_at = std::max<uint32_t>(1, esphome::millis());
refresh_tile(i);
}
@@ -812,10 +814,11 @@ inline void commit_slider(unsigned i,int raw){
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))));
if(d=="fan")action("fan.set_percentage",t.entity,"percentage",std::to_string((int)std::lround(value*100)));
// The slider stays where the finger left it while the light fades towards it (Tile::hold_slider).
if(d=="light"){int sent=std::max(3,(int)std::lround(value*255));action("light.turn_on",t.entity,"brightness",std::to_string(sent));t.hold_slider(esphome::millis(),sent);}
if(d=="fan"){int sent=(int)std::lround(value*100);action("fan.set_percentage",t.entity,"percentage",std::to_string(sent));t.hold_slider(esphome::millis(),sent);}
if(d=="cover")action("cover.set_cover_position",t.entity,"position",std::to_string((int)std::lround(value*100)));
if(d=="media_player")action("media_player.volume_set",t.entity,"volume_level",std::to_string(value));
if(d=="media_player"){action("media_player.volume_set",t.entity,"volume_level",std::to_string(value));t.hold_slider(esphome::millis(),value);}
if(d=="number"||d=="input_number") {
if(!std::isfinite(t.minimum)||!std::isfinite(t.maximum)||t.maximum<=t.minimum||t.step<=0)return;
value=std::clamp(t.minimum+std::round(value*(t.maximum-t.minimum)/t.step)*t.step,t.minimum,t.maximum);
@@ -3198,6 +3201,8 @@ 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);}
// A held slider whose light never got there shows what Home Assistant last reported again.
if(std::isfinite(t.slider_sent) && !t.slider_holding(esphome::millis())){t.release_slider();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();
+19
View File
@@ -176,6 +176,25 @@ 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.71 / firmware 0.2.60
Firmware only (`runtime_model.h`, `runtime_tiles.h`); the app raises `FIRMWARE_VERSION`. No protocol, storage or
editor change, and an older app drives this firmware as before.
- `Tile::hold_slider(now, value)`, called from `commit_slider` for a light (brightness 0-255), fan (percent) and
media player (volume 0-1), writes the sent value into the attribute (`slider_field`) and keeps what Home Assistant
reported in `slider_real`. A cover is left out: its position slider follows the blind as it moves. On an off light
or fan it also calls `optimistic(true)`, like a tap.
- `Tile::slider_reported(now)` runs in the state ingestion right after the attribute is parsed. It ends the hold when
the report is within 3 % of the target (`slider_span`), when `slider_active()` is false (off, unavailable), or on a
refusal; otherwise it keeps the sent value in front and notes `slider_state_at` when the reported value moved.
- `Tile::slider_holding(now)`: no report yet, hold while `waiting(now)` or once `answered_at` is set; after a report,
hold for `SLIDER_SETTLE` (1500 ms) since the last movement; never beyond `SLIDER_HOLD_CAP` (8000 ms) from the send.
The once-a-second tick calls `release_slider()` (puts `slider_real` back) when a hold has run out, and the refusal
callback does the same next to `undo_optimistic`.
- `slider_value(t)` and the tile's `%` label read the attribute, so every slider and label agrees without a change of
its own. Firmware before 0.2.60 keeps snapping to each report; the app sends nothing new.
### 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
+67
View File
@@ -0,0 +1,67 @@
# Test results app 0.2.71 / firmware 0.2.60 (2026-09-17)
Max, dragging the small slider of his living room light group on the Guition: after letting go the slider "sprints
back" to the old value and then creeps to the one he chose. The lamps fade, Home Assistant reports every step, and the
tile followed each report. This release keeps the slider where the finger left it while the light gets there. See
CHANGELOG 0.2.71 and the compatibility note in docs/RELEASING.md. Built on 0.2.70 (`91a2445`).
## What happened before
- The tile's small slider (`runtime_tiles.h`, the mini slider in `render`) took `slider_value(t)` on every state
message once the finger was up. The card's control slider already held its dragged value while the command was
pending, but only until the first differing state, which for a fading group is a step on the way.
- Home Assistant's own tile slider does the same: `ha-control-slider` follows the entity's state on every update, so
the jump shows there too. Nothing in Home Assistant holds the target.
## Automated
- Python (`.venv-portal`): 386 tests OK, the same set as 0.2.70 (firmware only; the app raises `FIRMWARE_VERSION`).
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 17/17 PASS. `tests/test_action_feedback.cpp` holds the new
rules: a fade reporting 80, 128 and 180 keeps the sent 204 in front and the hold alive, a report within 3 % ends it,
a fan that reports 66 once and stays quiet lets go after 1.5 s, no report at all ends with the wait at 3 s (or the
8 s cap after an "it worked" answer), a drag on an off light shows it on and a report of off wins, a refusal puts
the reported value back, and a cover never holds.
- `generate_packages.py --check` and `generate_icons.py --check` green.
## Firmware on the Mac (host builds, driven over the ESPHome API)
`slider_host.py` (scratchpad, after `tap_host.py`): the tap-actions demo home on 8297, the cooker hood light with its
small slider, a `drag_slider` action that sets the LVGL slider and sends its release as a finger would, and a probe
that logs the slider position, the tile's value label, the hold flag and the busy sheet. **Guition 17/17, CYD 17/17.**
| Check | Guition | CYD |
|---|---|---|
| The hood tile shows its small slider at 20 % | pass | pass |
| A drag to 80 % sends light.turn_on with brightness 204 | pass | pass |
| The slider stays at 80 % and the tile says 80 % | pass | pass |
| Reports of 80, 120 and 170 keep the slider at 80 %, no sheet | pass | pass |
| A report of 200 (within 3 %) takes over: 78 % | pass | pass |
| A report short of the target keeps the slider | pass | pass |
| After 1.5 s of quiet the slider follows that report | pass | pass |
| A drag without any report holds first | pass | pass |
| and lets go after the wait, showing the last report | pass | pass |
| A refused drag says Refused, then the last report shows again | pass | pass |
| A drag on an off light shows it on at 60 % | pass | pass |
| Home Assistant reporting it off wins | pass | pass |
| No errors in the log | pass | pass |
The first Guition run read two of these wrong: the host's LVGL loop stalled and answered five probes in one burst, so
a wait counted in tries ran out before the redraw. The harness now waits by the clock; the second run and the CYD run
passed every check.
## 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.70 |
|---|---|---|---|
| CYD | 24.0% (78,796 B) | 79.2% (1,453,683 B) | +400 B RAM, +332 B flash |
| Guition | 25.7% (84,372 B) | 21.0% (1,708,967 B) | +400 B RAM, +340 B flash |
The RAM is four fields per tile (the sent value, the reported value and two timestamps).
## Not tested
- Real hardware: neither bench screen ran this firmware before the release. Max's living room group on the Guition
is the case that started this; the Update round on his screens follows the push.
- A fan with fixed steps and a media player's volume were only covered by the C++ test, not on the host.
+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.59"
SCREEN_FIRMWARE_VERSION: "0.2.60"
# 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.59"
SCREEN_FIRMWARE_VERSION: "0.2.60"
# 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.59"
SCREEN_FIRMWARE_VERSION: "0.2.60"
# 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.59"
SCREEN_FIRMWARE_VERSION: "0.2.60"
# 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"
+9
View File
@@ -1,3 +1,12 @@
## 0.2.71 (firmware 0.2.60)
A small slider stays where you leave it while the lights fade towards it.
- **No jump back after a drag.** Drag the small slider on a light tile and let go, and it stays at that value while your lamps fade up or down. Before, a slow lamp or a group of lamps reported every step of the fade, and the slider jumped back to the old value and then crept up to the new one. The value on the tile follows the slider. The same goes for a fan's speed and a speaker's volume; a blind's position slider still follows the blind as it moves, because that is what it is for.
- **Home Assistant still has the last word.** The slider lets go the moment Home Assistant reports a value within 3 % of what you asked, reports the light off, or refuses. A device that stops short, such as a fan that only knows 33, 66 and 100 %, shows its own value a moment and a half after its last report. Without any report at all the slider goes back with the wait, after three seconds, and no hold lasts longer than eight.
- **A drag on an off light lights the tile up at once**, as a tap does since 0.2.70.
- Needs firmware 0.2.60: press **Update** on the screen. Includes everything from 0.2.70.
## 0.2.70 (firmware 0.2.59)
A tap looks instant, and the spinner only shows up when something really takes a while.
+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.59'
FIRMWARE_VERSION = '0.2.60'
# 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.70"
version: "0.2.71"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+44
View File
@@ -64,6 +64,50 @@ int main() {
assert(!lamp.optimistic_tap);
lamp.undo_optimistic(); assert(lamp.state=="off");
// A slider the finger let go stays put while the light fades towards it (firmware 0.2.60+).
runtime_tiles::Tile group;
group.entity="light.living_room"; group.received=true; group.state="on"; group.brightness=51; group.revision=rev("on");
group.hold_slider(1000,204); group.begin(1000);
assert(group.brightness==204); assert(group.slider_holding(1200));
// The fade reports 80, 128, 180: each keeps the sent value in front, and each keeps the hold alive.
for (float step : {80.0f, 128.0f, 180.0f}) {
group.brightness=step; group.observe(rev("on")); group.slider_reported(1500+(int)step*10);
assert(group.brightness==204); assert(group.slider_holding(1500+(int)step*10+1400));
}
// Within 3 % of the target the hold ends and the reported value shows.
group.brightness=200; group.slider_reported(4000);
assert(!std::isfinite(group.slider_sent)); assert(group.brightness==200);
// A fan that only knows 33/66/100 reports 66 once and then stays quiet: after 1.5 s the slider follows it.
runtime_tiles::Tile fan;
fan.entity="fan.attic"; fan.received=true; fan.state="on"; fan.percentage=33; fan.revision=rev("on");
fan.hold_slider(1000,50); fan.begin(1000);
fan.percentage=66; fan.observe(rev("on")); fan.slider_reported(1400);
assert(fan.percentage==50); assert(fan.slider_holding(2800)); assert(!fan.slider_holding(2900));
fan.release_slider(); assert(fan.percentage==66); assert(!std::isfinite(fan.slider_sent));
// No report at all: the hold ends with the wait, and what was there before comes back.
runtime_tiles::Tile lone;
lone.entity="light.lone"; lone.received=true; lone.state="on"; lone.brightness=100; lone.revision=rev("on");
lone.hold_slider(1000,200); lone.begin(1000);
assert(lone.slider_holding(3999)); assert(!lone.slider_holding(4000));
lone.release_slider(); assert(lone.brightness==100);
// "It worked" without a state (the value was already there) keeps it until the cap.
lone.hold_slider(5000,200); lone.begin(5000); lone.answered_at=5400;
assert(lone.slider_holding(9000)); assert(!lone.slider_holding(13000));
// A slider on an off light lights the tile up at once; Home Assistant reporting it off ends the hold.
runtime_tiles::Tile dark;
dark.entity="light.dark"; dark.received=true; dark.state="off"; dark.brightness=NAN; dark.revision=rev("off");
dark.hold_slider(1000,128); dark.begin(1000);
assert(dark.state=="on"); assert(dark.brightness==128);
dark.state="off"; dark.brightness=NAN; dark.observe(rev("off")); dark.slider_reported(1500);
assert(!std::isfinite(dark.slider_sent)); assert(!std::isfinite(dark.brightness));
// A refusal puts the reported value back.
dark.state="on"; dark.brightness=40; dark.hold_slider(2000,128); dark.begin(2000);
dark.refused_at=2500; assert(!dark.slider_holding(2600)); dark.release_slider(); assert(dark.brightness==40);
// A cover's position slider follows the blind as it moves: no hold.
runtime_tiles::Tile blind;
blind.entity="cover.blind"; blind.received=true; blind.state="open"; blind.position=20;
blind.hold_slider(1000,80); assert(!std::isfinite(blind.slider_sent)); assert(blind.position==20);
// The fingerprint follows state and attributes, and the streaming writer (what receive() hands to
// ArduinoJson) gives the same value as hashing the joined text.
assert(runtime_tiles::state_revision("on","{\"brightness\":255}")!=runtime_tiles::state_revision("on","{\"brightness\":254}"));