Files
homeassistant_espscreen/components/smart_display/light_controls.h
T
Max Gramser 08197c4ae1 Free memory on the CYD and drop the old manual profile (0.2.50)
The CYD restarted under load: a slider fill rounded less than its track made
LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and
when that allocation failed the refresh retried until the task watchdog fired.
Track and fill now share one radius, and tests/test_layer_free.py keeps every
layer-making style out of the profiles, the packages and the firmware headers.

A tile keeps only the fields every card uses; climate modes, select options,
weather, sun and timer times, the media title and the vacuum rows moved to an
Extra block that exists only while a state needs one, and the state copy became
a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes.

The manual profile that bound tiles to fixed entities in the YAML is gone:
331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home
Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its
guides and helper scripts, and the five stale header copies in the repository
root that two C++ tests were compiling instead of the components. esphome config
of both Easy profiles is unchanged by that cleanup.

tile_scroll and the wake overlay no longer light up on a tap, and the debug
sensor min_free reports the lowest free heap since boot. On the bench CYD static
RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with
78 KB as the lowest point over thirteen hours without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:06:41 +02:00

298 lines
15 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 "lvgl.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; };
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) { 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