Files
homeassistant_espscreen/components/smart_display/light_controls.h
T
Max Gramser 6df967bfef 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>
2026-09-16 11:15:40 +02:00

313 lines
16 KiB
C++

#pragma once
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
namespace light_controls {
inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
// HA serializes list attributes as either JSON lists or Python tuples.
inline bool first_number(const std::string &text, float &value) {
const char *p = text.c_str();
while (*p == ' ' || *p == '[' || *p == '(') ++p;
char *end;
float parsed = std::strtof(p, &end);
if (end == p || !std::isfinite(parsed)) return false;
value = parsed;
return true;
}
struct State {
const char *entity = "";
int hue = 0, kelvin = 3000, minimum = 0, maximum = 0;
bool temperature_ready() const { return minimum >= 1000 && maximum <= 15000 && maximum > minimum; }
void update(unsigned attribute, const std::string &text) {
float number;
if (!first_number(text, number)) return;
if (attribute == 0 && number >= 0 && number <= 360) hue = clamp(std::lround(number), 0, 360);
if (attribute == 1 && number >= 1000 && number <= 15000) kelvin = std::lround(number);
if (attribute == 2) minimum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
if (attribute == 3) maximum = number >= 1000 && number <= 15000 ? std::lround(number) : 0;
}
};
}
#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; int held = 0; };
inline Row rows[3];
inline std::function<void(int)> commits[3];
inline bool ready = false;
inline bool demo = false;
inline void state_updated(State *state);
inline int top, spacing;
inline void subscribe(const char *const *entities, size_t count) {
#ifdef USE_API_HOMEASSISTANT_STATES
static const char *attributes[] = {"hs_color", "color_temp_kelvin", "min_color_temp_kelvin", "max_color_temp_kelvin"};
size_t slot = 0;
for (size_t i = 0; i < count && slot < 8; ++i) {
if (std::string(entities[i]).rfind("light.", 0) != 0) continue;
bool duplicate = false;
for (size_t j = 0; j < slot; ++j) if (std::string(states[j].entity) == entities[i]) duplicate = true;
if (duplicate) continue;
State *state = &states[slot++];
state->entity = entities[i];
for (unsigned a = 0; a < 4; ++a)
esphome::api::global_api_server->subscribe_home_assistant_state(state->entity, attributes[a],
[state, a](esphome::StringRef value) { state->update(a, std::string(value.c_str(), value.size())); state_updated(state); });
}
#else
(void) entities;
(void) count;
#endif
}
// Temperature knob colour: the same warm-to-cool blend as the track under it.
inline lv_color_t kelvin_color(int value, int low, int high) {
int mix = high > low ? clamp((value - low) * 255 / (high - low), 0, 255) : 128;
return lv_color_mix(lv_color_hex(0xC6E6FF), lv_color_hex(0xFF9C32), mix);
}
inline void preview(Row &row) {
int value = lv_slider_get_value(row.slider);
if (row.index == 0) {
lv_label_set_text_fmt(row.value, "%d°", value);
lv_obj_set_style_bg_color(row.slider, lv_color_hsv_to_rgb(clamp(value, 0, 360) % 360, 100, 100), LV_PART_KNOB);
} else if (row.index == 1) {
lv_label_set_text_fmt(row.value, "%d K", value);
lv_obj_set_style_bg_color(row.slider, kelvin_color(value, lv_slider_get_min_value(row.slider), lv_slider_get_max_value(row.slider)), LV_PART_KNOB);
} else {
// The range reaches below 1 only to draw the short stub that holds the handle at 1 %.
if (value < 1) { lv_slider_set_value(row.slider, 1, LV_ANIM_OFF); value = 1; }
// An off light shows only the track, as in Home Assistant, until the slider moves.
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(row.off ? 0xECECEC : 0xFDEFC3), LV_PART_MAIN);
if (row.off) lv_label_set_text(row.value, "Off");
else lv_label_set_text_fmt(row.value, "%d %%", value);
}
}
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) {
// 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;
int value = lv_slider_get_value(row.slider);
if (row.index == 0) active->hue = value;
if (row.index == 1) {
if (!active->temperature_ready()) return;
value = clamp(value, active->minimum, active->maximum);
active->kelvin = value;
}
if (row.index == 2) value = clamp(value, 1, 100);
if (!demo && commits[row.index]) commits[row.index](value);
}
}
inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
auto *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_remove_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h);
return obj;
}
inline lv_obj_t *round_end(lv_obj_t *parent, int x, int y, int size, uint32_t color) {
auto *end = plain(parent, x, y, size, size);
lv_obj_set_style_radius(end, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(end, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(end, lv_color_hex(color), 0);
return end;
}
// Three white cards like the tiles: an icon, the name and the value, then a pill-shaped track.
// Colour and temperature are gradient pills with a round knob in the chosen colour; brightness
// is filled like the tile sliders, with the white handle inside the fill.
inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height, const lv_font_t *icon_font = nullptr) {
if (ready) return;
ready = true;
bool large = width >= 480;
top = large ? 100 : 52; spacing = large ? 122 : 60;
int margin = large ? 20 : 12, w = width - 2 * margin, card_h = spacing - (large ? 12 : 5);
int inset = large ? 18 : 10, text_y = large ? 14 : 5, track_h = large ? 32 : 18;
int track_x = inset, track_w = w - 2 * inset, track_y = card_h - inset + (large ? 2 : 3) - track_h, radius = track_h / 2;
const char *names[] = {"Color", "Color temperature", "Brightness"};
const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"};
const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
row.box = plain(parent, margin, top + i * spacing, w, card_h);
lv_obj_set_style_bg_opa(row.box, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(row.box, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_radius(row.box, large ? 18 : 10, 0);
lv_obj_set_style_border_width(row.box, 1, 0);
lv_obj_set_style_border_color(row.box, lv_color_hex(0xDDDDDD), 0);
lv_obj_set_style_text_font(row.box, font, 0);
lv_obj_set_style_text_color(row.box, lv_color_hex(0x1B1B1B), 0);
int text_x = inset;
if (icon_font) {
auto *icon = lv_label_create(row.box); lv_label_set_text(icon, icons[i]);
lv_obj_set_style_text_font(icon, icon_font, 0);
lv_obj_set_style_text_color(icon, lv_color_hex(0x616161), 0);
int icon_h = lv_font_get_line_height(icon_font), text_h = lv_font_get_line_height(font);
lv_obj_set_pos(icon, inset - (large ? 2 : 1), text_y + (text_h - icon_h) / 2);
text_x += icon_h + (large ? 6 : 4);
}
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, text_x, text_y);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -inset, text_y);
lv_obj_set_style_text_color(row.value, lv_color_hex(0x616161), 0);
row.slider = lv_slider_create(row.box);
lv_obj_remove_style_all(row.slider);
lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
if (i < 2) {
// The knob travels between the centres of the two round ends.
round_end(row.box, track_x, track_y, track_h, i == 0 ? rainbow[0] : 0xFF9C32);
round_end(row.box, track_x + track_w - track_h, track_y, track_h, i == 0 ? rainbow[6] : 0xC6E6FF);
unsigned segments = i == 0 ? 6 : 1;
int span = track_w - track_h;
for (unsigned n = 0; n < segments; ++n) {
int start = n * span / segments, end = (n + 1) * span / segments;
auto *stripe = plain(row.box, track_x + radius + start, track_y, end - start, track_h);
lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : 0xFF9C32), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : 0xC6E6FF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0);
}
lv_obj_move_foreground(row.slider);
lv_obj_set_pos(row.slider, track_x + radius, track_y); lv_obj_set_size(row.slider, span, track_h);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
lv_obj_set_style_pad_all(row.slider, large ? 4 : 3, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, large ? 4 : 3, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_outline_width(row.slider, 1, LV_PART_KNOB);
lv_obj_set_style_outline_color(row.slider, lv_color_hex(0x000000), LV_PART_KNOB);
lv_obj_set_style_outline_opa(row.slider, LV_OPA_20, LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : 2000, i == 0 ? 360 : 6500);
} else {
// Corners, handle and shortest fill as in Home Assistant's 42 px control slider. The fill keeps
// the track's radius: a smaller one makes LVGL draw the fill into its own full-size buffer on
// every redraw (20 KB on the CYD) and clip it to the track.
lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
int corner = track_h * 12 / 42;
lv_obj_set_style_radius(row.slider, corner, LV_PART_MAIN);
lv_obj_set_style_radius(row.slider, corner, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFDEFC3), LV_PART_MAIN);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFC107), LV_PART_INDICATOR);
// White handle inside the end of the fill, as on the tiles (runtime_tiles::slider_handle).
int handle = large ? 4 : 3, back = std::max(1, track_h / 8) + handle / 2, half = track_h >> 1;
lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_obj_set_style_radius(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_pad_left(row.slider, back + handle / 2 - half, LV_PART_KNOB);
lv_obj_set_style_pad_right(row.slider, handle / 2 - back - (track_h - half), LV_PART_KNOB);
lv_obj_set_style_pad_top(row.slider, -(track_h / 4), LV_PART_KNOB);
lv_obj_set_style_pad_bottom(row.slider, -(track_h / 4), LV_PART_KNOB);
// 1 % still shows a stub a third of the height wide: the range starts that far below 1.
int stub = std::max(track_h / 3, 2 * std::max(1, track_h / 8) + handle), below = track_w > stub ? 99 * stub / (track_w - stub) : 0;
lv_slider_set_range(row.slider, 1 - below, 100);
}
lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row);
preview(row);
}
}
// Late capability attributes may arrive after opening the card. Enable the
// waiting temperature slider without disturbing an in-progress gesture.
inline void state_updated(State *state) {
if (!ready || state != active || !state->temperature_ready()) return;
auto &row = rows[1];
if (!lv_obj_has_state(row.slider, LV_STATE_DISABLED)) return;
lv_slider_set_range(row.slider, state->minimum, state->maximum);
lv_slider_set_value(row.slider, state->kelvin, LV_ANIM_OFF);
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
preview(row); row.dirty = false;
}
inline void open(const std::string &entity, bool color, bool temperature, int brightness) {
active = &fallback;
for (auto &state : states) if (entity == state.entity) { active = &state; break; }
int y = top;
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i];
bool visible = i == 0 ? color : i == 1 ? temperature : true;
if (!visible) { lv_obj_add_flag(row.box, LV_OBJ_FLAG_HIDDEN); continue; }
lv_obj_remove_flag(row.box, LV_OBJ_FLAG_HIDDEN); lv_obj_set_y(row.box, y); y += spacing;
lv_obj_remove_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && !active->temperature_ready()) lv_obj_add_state(row.slider, LV_STATE_DISABLED);
if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum);
lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF);
row.off = i == 2 && brightness <= 0;
preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Waiting...");
}
}
inline bool self_test() {
auto *previous = active;
State saved_fallback = fallback;
int saved_values[3]; bool saved_hidden[3];
std::function<void(int)> saved[3];
int calls[3] = {0, 0, 0};
for (unsigned i = 0; i < 3; ++i) {
saved[i] = commits[i]; saved_values[i] = lv_slider_get_value(rows[i].slider);
saved_hidden[i] = lv_obj_has_flag(rows[i].box, LV_OBJ_FLAG_HIDDEN);
commits[i] = [&, i](int value) { if (value == (i == 0 ? 180 : i == 1 ? 4000 : 65)) ++calls[i]; };
}
fallback.minimum = 2200; fallback.maximum = 5000;
open("diagnostic", true, true, 50);
bool ok = true;
for (unsigned i = 0; i < 3; ++i) {
auto *slider = rows[i].slider;
lv_slider_set_value(slider, i == 0 ? 180 : i == 1 ? 4000 : 65, LV_ANIM_OFF);
for (int n = 0; n < 20; ++n) lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
ok &= calls[i] == 0;
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_send_event(slider, LV_EVENT_PRESS_LOST, nullptr);
lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr);
ok &= calls[i] == 1;
}
for (unsigned mask = 0; mask < 4; ++mask) {
open("diagnostic", mask & 1, mask & 2, 50);
ok &= lv_obj_has_flag(rows[0].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 1);
ok &= lv_obj_has_flag(rows[1].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 2);
ok &= !lv_obj_has_flag(rows[2].box, LV_OBJ_FLAG_HIDDEN);
}
fallback = saved_fallback;
open(previous->entity, !saved_hidden[0], !saved_hidden[1], saved_values[2]);
active = previous;
for (unsigned i = 0; i < 3; ++i) {
commits[i] = saved[i];
lv_slider_set_value(rows[i].slider, saved_values[i], LV_ANIM_OFF);
preview(rows[i]); rows[i].dirty = false;
}
return ok;
}
}
#endif