e928ee4404
A fast swipe off the right side of a Guition ended at 93-98 %: the GT911 reports the finger for the last time 33-48 px inside the edge while a wide tile's slider ends 29 px from it, and LVGL keeps the last point it saw. cyd::edge_snap() takes a release inside the band (SLIDER_EDGE_SNAP_PX: Guition 67, CYD 56) as the end that lies in that band, for the tile sliders and the light card's sliders; the log names each snap. App 0.2.80, firmware 0.2.67. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
339 lines
17 KiB
C++
339 lines
17 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 "theme.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{}, *icon{}; 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
|
|
}
|
|
// The picker's own spectrum: the colours a light can take, not a look, so they stay the same in dark.
|
|
constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;
|
|
inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};
|
|
// 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_hex(theme::mix(COOL, WARM, static_cast<uint8_t>(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, theme::color(row.off ? theme::TRACK : theme::AMBER_TRACK), 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;
|
|
// A finger let go within the edge band of the glass meant the slider's end (cyd::edge_snap).
|
|
if (auto *indev = lv_indev_active()) {
|
|
lv_point_t p;
|
|
lv_indev_get_point(indev, &p);
|
|
lv_area_t a;
|
|
lv_obj_get_coords(row.slider, &a);
|
|
const int screen = lv_display_get_horizontal_resolution(lv_obj_get_display(row.slider));
|
|
const int snap = cyd::edge_snap(p.x, a.x1, a.x2, screen, cyd::edge_snap_band);
|
|
if (snap) {
|
|
const int end = snap > 0 ? lv_slider_get_max_value(row.slider) : lv_slider_get_min_value(row.slider);
|
|
ESP_LOGI("slider", "Let go %d px from the %s edge: slider %d -> %d", snap > 0 ? screen - 1 - (int) p.x : (int) p.x, snap > 0 ? "right" : "left", lv_slider_get_value(row.slider), end);
|
|
lv_slider_set_value(row.slider, end, LV_ANIM_OFF);
|
|
preview(row);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
// The colours of the three cards, for setup() and for a change of look.
|
|
inline void paint(Row &row) {
|
|
lv_obj_set_style_bg_color(row.box, theme::color(theme::CARD), 0);
|
|
lv_obj_set_style_border_color(row.box, theme::color(theme::LINE), 0);
|
|
lv_obj_set_style_text_color(row.box, theme::color(theme::INK), 0);
|
|
lv_obj_set_style_text_color(row.value, theme::color(theme::MUTED), 0);
|
|
if (row.icon) lv_obj_set_style_text_color(row.icon, theme::color(theme::MUTED), 0);
|
|
if (row.index < 2) lv_obj_set_style_border_color(row.slider, theme::color(theme::KNOB), LV_PART_KNOB);
|
|
else lv_obj_set_style_bg_color(row.slider, theme::color(theme::KNOB), LV_PART_KNOB);
|
|
}
|
|
inline void restyle() {
|
|
if (!ready) return;
|
|
for (auto &row : rows) { paint(row); preview(row); }
|
|
}
|
|
// 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"};
|
|
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_radius(row.box, large ? 18 : 10, 0);
|
|
lv_obj_set_style_border_width(row.box, 1, 0);
|
|
lv_obj_set_style_text_font(row.box, font, 0);
|
|
int text_x = inset;
|
|
if (icon_font) {
|
|
auto *icon = row.icon = lv_label_create(row.box); lv_label_set_text(icon, icons[i]);
|
|
lv_obj_set_style_text_font(icon, icon_font, 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);
|
|
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] : WARM);
|
|
round_end(row.box, track_x + track_w - track_h, track_y, track_h, i == 0 ? RAINBOW[6] : COOL);
|
|
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] : WARM), 0);
|
|
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? RAINBOW[n + 1] : COOL), 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_outline_width(row.slider, 1, LV_PART_KNOB);
|
|
// A faint dark edge that lifts the white ring off a pale end of the track.
|
|
lv_obj_set_style_outline_color(row.slider, lv_color_black(), 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(theme::ha::AMBER), 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_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);
|
|
paint(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
|