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;
+30
View File
@@ -176,6 +176,36 @@ 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.55 / firmware 0.2.47
App, board profiles and `components/smart_display`. Storage, tile protocol, preferences and keys are unchanged.
- The app: `Manager.run()` compares the validated settings of an `esphome.screen_setting` event with the stored
ones and skips `save()` when they are equal. Before, every event saved the layout, `save()` dropped
`sent[inbox]`, and the next pass resent layout, header and every tile state.
- The four setting numbers (`setting_brightness`, `setting_standby_brightness`, `setting_night_brightness`,
`setting_standby_seconds`) return before `settings_preference.save` and `setting_event` when
`screen_settings::current` did not change (`Settings::operator==`). The Auto standby switch already did.
No state is published in that case: `apply_screen_settings` publishes numbers only when they differ.
- `runtime_tiles::receive`, op `layout`: `layout_changed()` and `refresh_all()` run only for changed or moved
tiles, the first layout, a rotation or settings change, or another `pages` count or title. A repeat still
sets `inbox`, `keepalive_seconds`, `layout_rev` and `last_received`; the tile states after it redraw their
own tiles through `refresh_tile`.
- Guition `on_boot` wraps the read callback of the LVGL touch input (ESPHome's `LVTouchListener`):
`cyd::GhostTouch` repeats the previous read when a pressed read lands exactly on the native (0, 0), mapped
with `LvglComponent::rotate_coordinates`, and logs `touch: GT911 stray contact at (0,0) ignored`.
`on_update` skips (0, 0) before `touch_guard` and `edge_swipe`. The CYD keeps its own XPT2046 `TouchFilter`.
- `cyd::release_jump` logs (tag `slider`, WARN) when LVGL's recalculation on release (`update_knob_pos(obj,
false)` for RELEASED in LVGL 9.5) moves a dragged slider to an end, more than a quarter of its range away
from where it was held: the three colour-card rows and the tile sliders.
- Measured on Studio 1 (firmware 0.2.43, 2026-09-16): `automation.studio_1_scherm_wakker_houden_bij_licht`, a
state trigger on a group of six lamps without `to:`, set `number.studio_1_standby_after` to its current
value after every colour change. The resulting `esphome.screen_setting` event triggered a full resync
(layout plus 13 states) 0.34 s later. Both red commits (`hs_color [0, 100]`) left 75-85 ms after such a
resync reached the screen. Earlier Studio 1 logs show a stray (0, 0) as the last sample in 4 of 90 traced
edge touches. ESPHome 2026.6.2's GT911 driver writes 0 to the status register before it reads the
coordinates; that ordering is a candidate for an upstream fix.
### Compatibility 0.2.54 / firmware 0.2.46
Board profiles; the app raises `FIRMWARE_VERSION` and adjusts the Auto standby row of the Claude skill.
+60
View File
@@ -0,0 +1,60 @@
# Test results app 0.2.55 / firmware 0.2.47 (2026-09-16)
A light's colour slider on Studio 1 sometimes turned red on release. The chain behind it, all measured
live on Studio 1 (firmware 0.2.43) and Max's Home Assistant: an automation with a state trigger on the
six-lamp group `light.studio_1_lights` and no `to:` set `number.studio_1_standby_after` to its current
value after every colour change. The screen reported it (`esphome.screen_setting`), ESP Screen Manager
saved the layout and resent layout, header and all 13 tile states, and the screen drew its whole page
again. While it did, it stopped reading the touch panel. The GT911's stray (0, 0) contact then became
the finger's last position, and LVGL moved the slider to its end on release. See CHANGELOG 0.2.55 and
the compatibility note in docs/RELEASING.md.
## Live measurements before the fix (read-only captures)
- 20 colour commits from Studio 1 captured as `call_service` events: 17 carried the colour under the
finger. Two were `hs_color [0, 100]` (red) on `light.studio_1_lights` that Max had not chosen, at
10:29:07.484 and 10:29:11.132. They left 75 ms and 84 ms after a full resync reached the screen. The
third, `[360, 100]` on the ambilight, Max chose on purpose.
- Every colour change on the lamp group was followed by layout, header and all tile states within
0.7 s. On `light.studio_2_ambilight`, which the automation does not watch, only that one tile was sent.
- The chain reproduced on demand, with Max's permission: `number.set_value` 86400 on
`number.studio_1_standby_after` (unchanged) → `esphome.screen_setting` at +0 ms → a dry replica of
the add-on logged `sent.pop by server.py:713 run <- server.py:526 save` → the real add-on sent layout,
header and 14 states at +341 ms.
- The colour card does not follow Home Assistant while open (no subscriptions since 0.2.50), so no
incoming message can move its knob directly. LVGL 9.5 sets a slider's value from the last touch point
on release (`update_knob_pos(obj, false)`), before the card's own RELEASED handler commits it.
- Earlier Studio 1 logs: a stray (0, 0) was the last sample in 4 of 90 traced edge touches, always just
before release (`swipe id=0 st=2 x=0 y=0`, then `st=6 x=0 y=0`).
## Automated
- Python (`.venv-portal`): 214 tests OK. New `tests/test_quiet_resync.py` (5 tests): an unchanged
setting event is neither saved nor resent, while a real change and a brightness that lowers the standby
brightness are. Every setting number in both profiles and both packages returns before its preference
write and event. `layout_changed()` / `refresh_all()` are guarded by the change conditions. The
Guition installs the (0, 0) filter and the edge swipe skips it, the CYD does not. Both slider handlers
log a jump on release.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 14/14 PASS.
`tests/test_cyd_ui.cpp` adds `GhostTouch` (a stray sample repeats the previous read, a touch cannot
start in the corner, the left edge itself and a rotated corner stay real touches) and `release_jump`.
- `generate_packages.py --check` green.
## Builds (ESPHome 2026.6.2)
Both Easy Setup profiles, one after the other, from the generated packages with local components:
| Profile | RAM | Flash | vs 0.2.54 |
|---|---|---|---|
| easy-guition-device.yaml | 24.0% (78,732 B) | 18.4% (1,496,775 B) | +40 B RAM, +1,468 B flash |
| easy-cyd-device.yaml | 23.0% (75,484 B) | 76.1% (1,396,799 B) | +16 B RAM, +1,116 B flash |
## Not tested / for the owner
- Hardware. Nothing was flashed. Studio 1 is Max's production screen, and its Wi-Fi password lives only in
ESPHome Device Builder, so Max updates it through Home Assistant. On real glass after the update:
drag the colour slider of the lamp group several times while the automation runs. Every commit should
carry the colour under the finger, the log should show no full resync after a colour change, and it may
show `touch: GT911 stray contact at (0,0) ignored`.
- A host build was not run for this release. The read-callback wrapper and the layout guard were only
compiled for the ESP32 boards.
+33 -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.46"
SCREEN_FIRMWARE_VERSION: "0.2.47"
# 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"
@@ -142,6 +142,26 @@ esphome:
// starving the touch polling for the whole length of a quick swipe.
lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE);
// A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last
// sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback.
if (auto *indev = lv_indev_get_next(nullptr)) {
static lv_indev_read_cb_t esphome_read = nullptr;
static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) {
esphome_read(input, data);
int32_t ghost_x = 0, ghost_y = 0;
id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y);
const unsigned dropped = cyd::ghost_touch.dropped();
const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y);
if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored");
data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
data->point.x = read.x;
data->point.y = read.y;
};
if (lv_indev_get_read_cb(indev) != filtered) {
esphome_read = lv_indev_get_read_cb(indev);
lv_indev_set_read_cb(indev, filtered);
}
}
id(last_touch_ms) = millis();
id(display_dimmed) = false;
- lambda: |-
@@ -1129,6 +1149,8 @@ touchscreen:
// Trace every sample of an edge touch: the log then shows how often the panel delivers.
if (cyd::edge_swipe.armed())
ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y);
// The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went.
if (point.x == 0 && point.y == 0) continue;
cyd::touch_guard.update(point.x, point.y, point.id);
if (point.id != cyd::touch_guard.contact()) continue;
// Swiping in from a side edge flips the page ("Swiping between pages"); the tap
@@ -4536,9 +4558,13 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
@@ -4555,9 +4581,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
@@ -4574,9 +4602,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
@@ -4593,9 +4623,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+11 -1
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.46"
SCREEN_FIRMWARE_VERSION: "0.2.47"
# 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.
@@ -4548,9 +4548,13 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
@@ -4567,9 +4571,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
@@ -4586,9 +4592,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
@@ -4605,9 +4613,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+11 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.46"
SCREEN_FIRMWARE_VERSION: "0.2.47"
# 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.
@@ -4537,9 +4537,13 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
@@ -4556,9 +4560,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
@@ -4575,9 +4581,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
@@ -4594,9 +4602,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+33 -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.46"
SCREEN_FIRMWARE_VERSION: "0.2.47"
# 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"
@@ -146,6 +146,26 @@ esphome:
// starving the touch polling for the whole length of a quick swipe.
lv_obj_remove_flag(id(home_page)->obj, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(id(tile_scroll), LV_OBJ_FLAG_CLICKABLE);
// A stray GT911 contact at exactly (0, 0) never reaches LVGL (cyd::GhostTouch): as the last
// sample of a drag it sent a slider to its end on release. LVGL still reads ESPHome's callback.
if (auto *indev = lv_indev_get_next(nullptr)) {
static lv_indev_read_cb_t esphome_read = nullptr;
static const lv_indev_read_cb_t filtered = [](lv_indev_t *input, lv_indev_data_t *data) {
esphome_read(input, data);
int32_t ghost_x = 0, ghost_y = 0;
id(screen_lvgl).rotate_coordinates(ghost_x, ghost_y);
const unsigned dropped = cyd::ghost_touch.dropped();
const auto read = cyd::ghost_touch.filter({data->state == LV_INDEV_STATE_PRESSED, (int) data->point.x, (int) data->point.y}, ghost_x, ghost_y);
if (cyd::ghost_touch.dropped() != dropped) ESP_LOGI("touch", "GT911 stray contact at (0,0) ignored");
data->state = read.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
data->point.x = read.x;
data->point.y = read.y;
};
if (lv_indev_get_read_cb(indev) != filtered) {
esphome_read = lv_indev_get_read_cb(indev);
lv_indev_set_read_cb(indev, filtered);
}
}
id(last_touch_ms) = millis();
id(display_dimmed) = false;
- lambda: |-
@@ -1118,6 +1138,8 @@ touchscreen:
// Trace every sample of an edge touch: the log then shows how often the panel delivers.
if (cyd::edge_swipe.armed())
ESP_LOGI("touch", "swipe id=%u st=%u x=%u y=%u", (unsigned) point.id, (unsigned) point.state, point.x, point.y);
// The stray (0, 0) contact (cyd::GhostTouch) is not where the finger went.
if (point.x == 0 && point.y == 0) continue;
cyd::touch_guard.update(point.x, point.y, point.id);
if (point.id != cyd::touch_guard.contact()) continue;
// Swiping in from a side edge flips the page ("Swiping between pages"); the tap
@@ -4525,9 +4547,13 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
// The value the screen already has (an automation that sets it on every light change):
// no flash write, no event, so ESP Screen Manager does not resend the whole screen.
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("brightness",s.brightness);
id(apply_screen_settings).execute();
@@ -4544,9 +4570,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_brightness",s.standby_brightness);
id(apply_screen_settings).execute();
@@ -4563,9 +4591,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.night_brightness = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("night_brightness",s.night_brightness);
id(apply_screen_settings).execute();
@@ -4582,9 +4612,11 @@ number:
set_action:
- lambda: |-
auto &s = screen_settings::current;
const auto before = s;
s.standby_seconds = (int)x;
s.standby_brightness = std::min(s.standby_brightness,s.brightness);
s.night_brightness = std::min(s.night_brightness,s.brightness);
if (s == before) return;
if (runtime_tiles::enabled) runtime_tiles::settings_preference.save(&s);
runtime_tiles::setting_event("standby_seconds",s.standby_seconds);
id(apply_screen_settings).execute();
+9
View File
@@ -1,3 +1,12 @@
## 0.2.55 (firmware 0.2.47)
A colour slider no longer turns red when you let go.
- **Sliders keep the value you let go of.** On a Guition, dragging a light's colour slider and letting go could send red instead of the colour under your finger, and brightness or colour temperature could jump to an end the same way. The touch panel now and then reports a stray touch in the top-left corner just as the finger lifts, and the screen took that as where the finger was. The screen now ignores it. Should a slider still jump when you let go, the ESPHome log says `slider jumped on release`.
- **Setting a screen to what it already has costs nothing.** An automation that sets Standby after or a brightness whenever a light changes runs on every colour and brightness change too, because a state trigger without `to:` also fires on those. Each time, ESP Screens sent the whole screen again, and while the screen redrew its page it briefly stopped reading the touch panel: exactly when you were dragging that light's slider. A value the screen already has is now neither saved on the screen nor sent again by ESP Screens.
- **A repeated layout no longer redraws the page.** The hourly repeat, and a save that changed nothing for this screen, only update the tiles whose state comes in, instead of rebuilding the whole page behind an open card.
- Needs firmware 0.2.47: press **Update** on the screen.
## 0.2.54 (firmware 0.2.46)
The standby code, tidied after a review of Wake and Sleep.
+1 -1
View File
@@ -17,7 +17,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.46'
FIRMWARE_VERSION = '0.2.47'
# 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.
+6 -2
View File
@@ -704,13 +704,17 @@ class Manager:
try:
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
if inbox in self.layouts:
layout=dict(self.layouts[inbox]); settings=validate_settings(layout.get('settings',{}))
layout=dict(self.layouts[inbox]); stored=validate_settings(layout.get('settings',{})); settings=dict(stored)
if key not in settings: continue
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
if key=='brightness':
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
settings['night_brightness']=min(settings['night_brightness'],settings[key])
layout['settings']=validate_settings(settings);self.save(inbox,layout)
settings=validate_settings(settings)
# A screen reporting what it already has (an automation setting the same value on
# every light change): saving would resend the whole screen while someone uses it.
if settings==stored: continue
layout['settings']=settings;self.save(inbox,layout)
except (ValueError,TypeError): pass
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.54"
version: "0.2.55"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+19
View File
@@ -136,4 +136,23 @@ int main() {
assert(rollover.accept(50, 1)); // millis wraps after 49 days
rollover.begin(60);
assert(!rollover.accept(130, 1));
// GT911 stray (0, 0): measured on Studio 1 as the last sample of a drag before lift-off.
cyd::GhostTouch ghost;
auto same = [](cyd::PointerRead a, bool pressed, int x, int y) { return a.pressed == pressed && a.x == x && a.y == y; };
assert(same(ghost.filter({false, 0, 0}, 0, 0), false, 0, 0)); // released: nothing to hide
assert(same(ghost.filter({true, 0, 0}, 0, 0), false, 0, 0)); // a touch cannot start in the corner
assert(same(ghost.filter({true, 302, 250}, 0, 0), true, 302, 250)); // the finger on blue
assert(same(ghost.filter({true, 0, 0}, 0, 0), true, 302, 250)); // stray sample: still on blue
assert(same(ghost.filter({true, 0, 0}, 0, 0), true, 302, 250)); // and again
assert(same(ghost.filter({false, 302, 250}, 0, 0), false, 302, 250)); // released where the finger was
assert(ghost.dropped() == 3);
assert(same(ghost.filter({true, 0, 240}, 0, 0), true, 0, 240)); // the left edge itself is a real touch
assert(same(ghost.filter({true, 479, 479}, 479, 479), true, 0, 240)); // rotated 180: (0, 0) is that corner
assert(same(ghost.filter({true, 0, 0}, 479, 479), true, 0, 0)); // and the top-left is a real pixel there
// A dragged slider sent to its end on release is reported; a drag that ends there is not a jump.
assert(cyd::release_jump(240, 0, 0, 360)); // hue: blue to red at the start
assert(cyd::release_jump(120, 360, 0, 360)); // green to red at the end
assert(!cyd::release_jump(20, 0, 0, 360)); // dragged to the start and let go
assert(!cyd::release_jump(240, 238, 0, 360)); // a few pixels on lift-off
assert(cyd::release_jump(80, -6, -6, 100)); // brightness: 80 % to the stub below 1 %
}
+109
View File
@@ -0,0 +1,109 @@
"""A colour slider on Studio 1 turned red on release (app 0.2.55, firmware 0.2.47).
An automation set a screen setting to the value it already had on every light change. The screen reported
it, ESP Screen Manager saved the layout and resent the whole screen, and the screen drew its whole page
again while a finger was on a slider. Touch input stalled, and the GT911's stray (0, 0) contact became
the finger's last position, so LVGL moved the slider to its end on release. Each link is checked here.
"""
import asyncio
import importlib.util
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
from core import validate_settings # noqa: E402
PROFILES = ('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml')
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
LIGHT_CONTROLS = (ROOT / 'components/smart_display/light_controls.h').read_text()
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class SettingEvents(unittest.IsolatedAsyncioTestCase):
async def run_loop_for(self, manager, seconds):
task = asyncio.create_task(manager.run())
await asyncio.sleep(seconds)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def test_a_setting_the_screen_already_has_is_not_saved_or_resent(self):
import test_scaling
from server import Manager
with tempfile.TemporaryDirectory() as tmp:
ha = test_scaling.fake_ha(firmware='0.2.47')
ha.setting_events = []
m = Manager(ha, Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Studio', 'tiles': [{'entity': 'light.a', 'name': ''}],
'settings': validate_settings({'standby_seconds': 86400})})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual([msg['op'] for _, msg, _ in ha.messages], ['layout', 'header', 'state'], 'first pass sends everything')
ha.messages.clear()
stored = m.layouts['text.screen']
# An automation sets "Standby after" to 86400 on every light change; the screen reports it.
ha.setting_events.append({'inbox': 'text.screen', 'key': 'standby_seconds', 'value': '86400'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual(ha.messages, [], 'nothing changed: no resend of the whole screen')
self.assertIs(m.layouts['text.screen'], stored, 'and no save')
self.assertEqual(ha.setting_events, [], 'the event is handled all the same')
# A real change is still saved and sent.
ha.setting_events.append({'inbox': 'text.screen', 'key': 'standby_seconds', 'value': '600'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
self.assertEqual(m.layouts['text.screen']['settings']['standby_seconds'], 600)
self.assertIn('layout', [msg['op'] for _, msg, _ in ha.messages])
# So is a brightness that pulls the standby brightness down with it.
ha.messages.clear()
ha.setting_events.append({'inbox': 'text.screen', 'key': 'brightness', 'value': '10'})
ha.changed.set()
await self.run_loop_for(m, 0.6)
settings = m.layouts['text.screen']['settings']
self.assertEqual((settings['brightness'], settings['standby_brightness']), (10, 10))
class Firmware(unittest.TestCase):
def setUp(self):
self.profiles = {name: (ROOT / name).read_text() for name in PROFILES}
def test_home_assistant_setting_the_same_value_writes_and_reports_nothing(self):
for name, text in self.profiles.items():
for key in ('brightness', 'standby_brightness', 'night_brightness', 'standby_seconds'):
action = re.search(r'const auto before = s;\n(?:.*\n){1,6}?\s+if \(s == before\) return;\n'
r'\s+if \(runtime_tiles::enabled\) runtime_tiles::settings_preference\.save\(&s\);\n'
rf'\s+runtime_tiles::setting_event\("{key}",', text)
self.assertTrue(action, f'{name}: {key} saves or reports a value it already has')
def test_a_repeated_layout_does_not_redraw_the_page(self):
block = RUNTIME[RUNTIME.index('if (op == "layout")'):RUNTIME.index('if (op == "ping")')]
guard = re.search(r'if \((.*)\) \{\s+if \(layout_changed\) layout_changed\(\);\s+refresh_all\(\);\s+\}', block)
self.assertTrue(guard, 'layout_changed/refresh_all must only run for a layout that differs')
for reason in ('changed', 'moved', '!was_configured', 'rotation_changed', 'model.pages != previous_pages',
'model.title != previous_title'):
self.assertIn(reason, guard[1])
self.assertEqual(block.count('layout_changed()'), 1)
def test_the_guition_hides_the_stray_gt911_contact_from_lvgl_and_the_edge_swipe(self):
for name in ('guition-4848s040.yaml', 'packages/guition.yaml'):
text = self.profiles[name]
self.assertIn('cyd::ghost_touch.filter(', text, name)
self.assertIn('lv_indev_set_read_cb(indev, filtered);', text, name)
self.assertRegex(text, r'if \(point\.x == 0 && point\.y == 0\) continue;\s+cyd::touch_guard\.update', name)
for name in ('home-like-2432s028.yaml', 'packages/cyd.yaml'):
self.assertNotIn('ghost_touch', self.profiles[name], 'the CYD has its own XPT2046 filter')
def test_a_slider_that_jumps_on_release_is_logged(self):
self.assertIn('cyd::release_jump(row.held, value,', LIGHT_CONTROLS)
self.assertIn('cyd::release_jump(slider_held,raw,', RUNTIME)
if __name__ == '__main__':
unittest.main()