Keep a slider's value when you let go, and stop needless full resends (0.2.55)

A light's colour slider on Studio 1 sometimes sent red on release. Measured
live: an automation with a state trigger on a six-lamp group (no `to:`) set
"Standby after" to its current value after every colour change. The screen
reported the setting, ESP Screen Manager saved the layout and resent layout,
header and every tile state, and the screen drew its whole page again,
reading no touch input meanwhile. The GT911's stray (0, 0) contact then
became the finger's last position, and LVGL 9.5 set the slider from it on
release. Both red commits left 75-85 ms after such a resync.

The app no longer saves or resends a setting event whose value the layout
already has. The four setting numbers return before the preference write
and the event when nothing changed. A layout message that changes nothing
on the screen no longer runs layout_changed() and refresh_all(); the tile
states after it redraw their own tiles. The Guition wraps the LVGL touch
read callback with cyd::GhostTouch, which repeats the previous read for a
pressed (0, 0), and the edge swipe skips that sample too. Both slider
handlers log a jump to an end on release.

Checked with 214 Python and 14 C++ tests, generate_packages --check, and
ESP32 builds of both Easy profiles. Firmware 0.2.47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 11:15:40 +02:00
parent f3af5c2125
commit 6df967bfef
15 changed files with 393 additions and 12 deletions
+33
View File
@@ -183,4 +183,37 @@ class EdgeSwipe {
bool done_{true};
};
inline EdgeSwipe edge_swipe;
// The GT911 (Guition) now and then reports a contact at exactly (0, 0), mostly as the last
// sample before the finger lifts. LVGL takes the last pressed sample as the finger's position
// on release, so a slider jumped to its end and sent that value (a colour turning red). The
// corner pixel is never a real touch: such a sample repeats the read before it instead.
struct PointerRead {
bool pressed;
int x, y;
};
class GhostTouch {
public:
// `ghost_x`, `ghost_y`: where the native (0, 0) lands after the display rotation.
PointerRead filter(PointerRead read, int ghost_x, int ghost_y) {
if (read.pressed && read.x == ghost_x && read.y == ghost_y) {
++dropped_;
return last_;
}
last_ = read;
return read;
}
unsigned dropped() const { return dropped_; }
private:
PointerRead last_{false, 0, 0};
unsigned dropped_{0};
};
inline GhostTouch ghost_touch;
// A dragged slider that LVGL moved to one of its ends on release, far from where the finger held
// it: the touch panel reported a stray point. Worth a log line, the finger was never there.
inline bool release_jump(int held, int released, int minimum, int maximum) {
const bool at_end = released <= minimum || released >= maximum;
return at_end && std::abs(released - held) * 4 > maximum - minimum;
}
} // namespace cyd
+17 -2
View File
@@ -34,13 +34,15 @@ struct State {
#ifndef LIGHT_CONTROLS_TEST
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "lvgl.h"
#include "cyd_ui.h"
#include <functional>
namespace light_controls {
inline State states[8];
inline State fallback;
inline State *active = &fallback;
struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; bool off = false; };
struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; bool off = false; int held = 0; };
inline Row rows[3];
inline std::function<void(int)> commits[3];
inline bool ready = false;
@@ -94,7 +96,20 @@ inline void preview(Row &row) {
inline void event(lv_event_t *e) {
auto &row = *static_cast<Row *>(lv_event_get_user_data(e));
auto code = lv_event_get_code(e);
if (code == LV_EVENT_VALUE_CHANGED) { row.dirty = true; row.off = false; preview(row); }
if (code == LV_EVENT_VALUE_CHANGED) {
// On release LVGL sets the value once more from the last touch point; say so when that throws
// a dragged slider to one of its ends, so a stray touch sample shows up in the log.
auto *indev = lv_indev_active();
const int value = lv_slider_get_value(row.slider);
if (!indev || lv_indev_get_state(indev) != LV_INDEV_STATE_RELEASED) row.held = value;
else if (row.dirty && cyd::release_jump(row.held, value, lv_slider_get_min_value(row.slider), lv_slider_get_max_value(row.slider))) {
static const char *const names[] = {"Color", "Color temperature", "Brightness"};
lv_point_t point;
lv_indev_get_point(indev, &point);
ESP_LOGW("slider", "%s slider jumped on release: %d -> %d (touch x=%d y=%d)", names[row.index], row.held, value, (int) point.x, (int) point.y);
}
row.dirty = true; row.off = false; preview(row);
}
if (code == LV_EVENT_PRESS_LOST) row.dirty = false;
if (code == LV_EVENT_RELEASED && row.dirty) {
row.dirty = false;
+20 -2
View File
@@ -222,6 +222,8 @@ inline std::string receive(const std::string &payload) {
}
inbox = string(root["inbox"], 160);
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;
// 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;
@@ -249,8 +251,13 @@ inline std::string receive(const std::string &payload) {
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
layout_rev = string(root["rev"], 16);
last_received = esphome::millis();
if (layout_changed) layout_changed();
refresh_all();
// A repeat of the layout on screen (the hourly repeat, a save that changed nothing here) only
// refreshes the feed: drawing the whole page again stalls touch input for a few hundred ms,
// long enough to spoil a slider drag. The tile states that follow redraw their own tiles.
if (changed || moved || !was_configured || rotation_changed || model.pages != previous_pages || model.title != previous_title) {
if (layout_changed) layout_changed();
refresh_all();
}
// A repeat of the same layout keeps the inbox state as it is: no new recorder row.
result = changed || moved || !was_configured ? "Layout received" : model.ready() ? "Synced" : "Loading tiles";
return true;
@@ -507,6 +514,7 @@ inline int slider_value(const Tile &t){
}
inline lv_obj_t *captured_slider=nullptr;
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;
@@ -524,6 +532,16 @@ inline void commit_slider(unsigned i,int raw){
inline void slider_event(lv_event_t *e){
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e);
if(code==LV_EVENT_PRESSED){captured_slider=slider;slider_changed=false;}
// On release LVGL sets the value once more from the last touch point: log it when that throws a
// dragged slider to one of its ends, so a stray touch sample shows up (cyd::release_jump).
if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider){
auto *indev=lv_indev_active();int raw=lv_slider_get_value(slider);
if(!indev || lv_indev_get_state(indev)!=LV_INDEV_STATE_RELEASED)slider_held=raw;
else if(slider_changed && cyd::release_jump(slider_held,raw,lv_slider_get_min_value(slider),lv_slider_get_max_value(slider))){
lv_point_t point;lv_indev_get_point(indev,&point);
ESP_LOGW("slider","Tile slider jumped on release: %d -> %d (touch x=%d y=%d)",slider_held,raw,(int)point.x,(int)point.y);
}
}
// The range reaches below zero only to draw the round end at 0 (slider_handle).
if(code==LV_EVENT_VALUE_CHANGED && lv_slider_get_value(slider)<0)lv_slider_set_value(slider,0,LV_ANIM_OFF);
if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider)slider_changed=true;