#pragma once #include #include #include #include #include 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 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 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(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(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_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 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