Add Home Assistant screen manager and runtime tile configuration

This commit is contained in:
Max Gramser
2026-09-12 12:09:58 +02:00
parent 427e4049ab
commit d271a63241
43 changed files with 13991 additions and 388 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Runtime tile configuration transported through the normal ESPHome API."""
CODEOWNERS = []
AUTO_LOAD = ["json"]
@@ -0,0 +1,57 @@
#pragma once
#include <array>
#include <algorithm>
#include <cmath>
namespace screen_calibration {
struct Point { double x{}, y{}; };
struct Calibration { float c[6] = {1,0,0,0,1,0}; int bounds[4] = {280,3860,340,3860}; };
constexpr Point TARGETS[5] = {{20,20}, {299,20}, {299,219}, {20,219}, {160,120}};
inline bool solve(double matrix[3][4], double out[3]) {
for (int column = 0; column < 3; ++column) {
int pivot = column;
for (int row = column + 1; row < 3; ++row) if (std::abs(matrix[row][column]) > std::abs(matrix[pivot][column])) pivot = row;
for (int j = 0; j < 4; ++j) std::swap(matrix[column][j], matrix[pivot][j]);
double scale = matrix[column][column];
if (std::abs(scale) < 1e-8) return false;
for (int j = 0; j < 4; ++j) matrix[column][j] /= scale;
for (int row = 0; row < 3; ++row) if (row != column) {
double factor = matrix[row][column];
for (int j = 0; j < 4; ++j) matrix[row][j] -= factor * matrix[column][j];
}
}
for (int i = 0; i < 3; ++i) out[i] = matrix[i][3];
return true;
}
inline Point project(const Calibration &cal, Point raw) {
auto c = cal.c; auto b = cal.bounds;
return {(c[3]*raw.x+c[4]*raw.y+c[5]-b[2])*320/(b[3]-b[2]),
(c[0]*raw.x+c[1]*raw.y+c[2]-b[0])*240/(b[1]-b[0])};
}
inline bool fit(const std::array<Point,5> &raw, Calibration &cal) {
if (cal.bounds[1] <= cal.bounds[0] || cal.bounds[3] <= cal.bounds[2]) return false;
for (int axis = 0; axis < 2; ++axis) {
double matrix[3][4] = {};
for (int n = 0; n < 4; ++n) {
double row[3] = {raw[n].x/4095, raw[n].y/4095, 1};
double target = axis == 0 ? cal.bounds[0]+TARGETS[n].y*(cal.bounds[1]-cal.bounds[0])/240
: cal.bounds[2]+TARGETS[n].x*(cal.bounds[3]-cal.bounds[2])/320;
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) matrix[i][j] += row[i]*row[j];
matrix[i][3] += row[i]*target;
}
}
double coefficients[3];
if (!solve(matrix, coefficients)) return false;
for (int i = 0; i < 3; ++i) {
cal.c[axis*3+i] = coefficients[i] / (i == 2 ? 1 : 4095);
if (!std::isfinite(cal.c[axis*3+i]) || (i != 2 && std::abs(cal.c[axis*3+i]) > 4)) return false;
}
}
// Fifth point is held out of the fit: it independently checks the result.
for (int n = 0; n < 5; ++n) {
Point p = project(cal, raw[n]);
if (std::hypot(p.x-TARGETS[n].x,p.y-TARGETS[n].y) > 12) return false;
}
return true;
}
}
@@ -0,0 +1,38 @@
#pragma once
#include <string>
#include <cstdint>
namespace runtime_tiles {
// HA text.set_value is limited to 255 characters. Chunks contain base64 ASCII.
// A complete, ordered message is required before any configuration is applied.
class Chunks {
public:
std::string data;
bool complete = false;
bool accept(const std::string &packet, uint32_t now) {
complete = false;
if (packet.size() > 255) return false;
auto a = packet.find('|'), b = packet.find('|', a + 1), c = packet.find('|', b + 1);
if (a == std::string::npos || b == std::string::npos || c == std::string::npos || a == 0 || a > 24) return false;
std::string token = packet.substr(0, a), number = packet.substr(a + 1, b - a - 1);
if (number.empty() || number.size() > 2) return false;
unsigned index = 0;
for (char ch : number) { if (ch < '0' || ch > '9') return false; index = index * 10 + ch - '0'; }
if (index > 31 || c != b + 2 || (packet[b+1] != '0' && packet[b+1] != '1')) return false;
auto chunk = packet.substr(c + 1);
if (chunk.empty() || chunk.size() > 200) return false;
for (char ch : chunk) if (!(ch >= 'A' && ch <= 'Z') && !(ch >= 'a' && ch <= 'z') && !(ch >= '0' && ch <= '9') && ch != '+' && ch != '/' && ch != '=') return false;
if (index == 0) { data.clear(); token_ = token; next_ = 0; }
else if (now - last_ > 10000) { reset(); return false; }
if (token != token_ || index != next_ || data.size() + chunk.size() > 5464) { reset(); return false; }
data += chunk; next_++; last_ = now;
complete = packet[b+1] == '1';
if (complete) token_.clear();
return true;
}
void reset() { data.clear(); token_.clear(); next_ = 0; complete = false; }
private:
std::string token_;
unsigned next_ = 0;
uint32_t last_ = 0;
};
}
@@ -0,0 +1,80 @@
#pragma once
#include "calibration_math.h"
#include "esphome/core/preferences.h"
#include "esphome/core/hal.h"
#include "lvgl.h"
#include <functional>
namespace screen_calibration {
inline bool active = false, pressed = false;
inline unsigned point = 0, sample = 0;
inline uint32_t down_at = 0;
inline lv_obj_t *screen{}, *home{}, *cross{}, *instructions{}, *status{};
inline Calibration calibration;
inline std::array<std::array<Point,3>,5> samples;
inline esphome::ESPPreferenceObject preference;
inline std::function<void(const Calibration &)> apply;
inline std::function<void(bool)> isolation;
inline void prompt(const char *message = "Houd elke tik even vast") {
lv_obj_set_pos(cross, TARGETS[point].x-12, TARGETS[point].y-12);
lv_label_set_text_fmt(instructions, "Tik op het kruisje\nPunt %u/5 - tik %u/3", point+1, sample+1);
lv_label_set_text(status, message);
}
inline void begin() {
if (!screen) return;
active = true; pressed = false; point = sample = 0;
if (isolation) isolation(true);
prompt(); lv_screen_load(screen);
}
inline void setup(lv_obj_t *home_screen, const lv_font_t *font, const lv_font_t *large,
int xmin, int xmax, int ymin, int ymax) {
if (screen) return;
home = home_screen;
calibration.bounds[0]=xmin; calibration.bounds[1]=xmax; calibration.bounds[2]=ymin; calibration.bounds[3]=ymax;
preference = esphome::global_preferences->make_preference<Calibration>(0x43594403);
Calibration stored;
bool valid = preference.load(&stored);
for (int i = 0; i < 4; ++i) valid &= stored.bounds[i] == calibration.bounds[i];
for (float coefficient : stored.c) valid &= std::isfinite(coefficient);
screen = lv_obj_create(nullptr);
lv_obj_set_style_bg_color(screen, lv_color_hex(0x101820), 0);
lv_obj_set_style_text_color(screen, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(screen, font, 0);
instructions = lv_label_create(screen);
lv_obj_set_style_text_align(instructions, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(instructions, LV_ALIGN_CENTER, 0, -40);
status = lv_label_create(screen); lv_obj_align(status, LV_ALIGN_BOTTOM_MID, 0, -42);
cross = lv_label_create(screen); lv_label_set_text(cross, "+");
lv_obj_set_size(cross, 24, 24); lv_obj_set_style_text_font(cross, large, 0);
lv_obj_set_style_text_align(cross, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(cross, lv_color_hex(0xFFD34D), 0);
if (valid) { calibration = stored; if (apply) apply(calibration); }
else begin();
}
inline void press() { if (active) { down_at = esphome::millis(); pressed = true; } }
inline void release(int raw_x, int raw_y) {
if (!active || !pressed) return;
pressed = false;
if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tik rustig, iets langer"); return; }
samples[point][sample++] = {double(raw_x), double(raw_y)};
if (sample < 3) { prompt(); return; }
double xs[3], ys[3];
for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; }
std::sort(xs,xs+3); std::sort(ys,ys+3);
if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tik 3x op hetzelfde kruisje"); return; }
point++; sample=0;
if (point < 5) { prompt(); return; }
std::array<Point,5> raw;
for (unsigned n=0;n<5;++n) {
for (int i=0;i<3;++i) { xs[i]=samples[n][i].x; ys[i]=samples[n][i].y; }
std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]};
}
Calibration next = calibration;
if (!fit(raw,next)) { point=0;prompt("Meting afgekeurd; probeer opnieuw");return; }
if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Opslaan mislukt; probeer opnieuw");return; }
calibration=next;
if (apply) apply(calibration);
active=false;
if (isolation) isolation(false);
lv_screen_load(home);
}
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace cyd {
// Work in tenths of a degree and always clamp AFTER rounding.
inline int quantize_temperature(int value, int minimum, int maximum, int step) {
step = std::max(1, step);
value = std::clamp(value, minimum, maximum);
const int snapped = minimum + static_cast<int>(std::lround(
static_cast<double>(value - minimum) / step)) * step;
return std::clamp(snapped, minimum, maximum);
}
class TouchGuard {
public:
void begin(uint32_t now, int x = 0, int y = 0) {
started_ = now;
accepted_ = false;
moved_ = false;
start_x_ = x;
start_y_ = y;
}
void update(int x, int y) {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
}
bool accept(uint32_t now, int tile) {
if (accepted_ || moved_ || now - started_ < 60) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
return true;
}
private:
uint32_t started_{0}, previous_{0};
int previous_tile_{-1};
int start_x_{0}, start_y_{0};
bool moved_{false};
bool accepted_{false}, has_previous_{false};
};
inline TouchGuard touch_guard;
} // namespace cyd
@@ -0,0 +1,59 @@
#pragma once
#include "lvgl.h"
#include "esphome/core/log.h"
// On-demand 240x240 diagnostic image of LVGL's rendered screen. Nothing is
// allocated or streamed during normal use. The RGB display signal itself
// still needs a physical visual check.
namespace guition {
inline bool check_geometry(lv_obj_t *obj) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return true;
bool ok = true;
lv_area_t area;
lv_obj_get_coords(obj, &area);
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_CLICKABLE) &&
(area.x1 < 0 || area.y1 < 0 || area.x2 >= 480 || area.y2 >= 480)) {
ESP_LOGE("ui_test", "GEOMETRY FAIL clickable=(%d,%d)-(%d,%d)",
area.x1, area.y1, area.x2, area.y2);
ok = false;
}
for (uint32_t i = 0; i < lv_obj_get_child_count(obj); ++i)
ok = check_geometry(lv_obj_get_child(obj, i)) && ok;
return ok;
}
#if LV_USE_SNAPSHOT
inline lv_draw_buf_t *snapshot = nullptr;
inline int snapshot_row = 0;
inline void snapshot_begin() {
if (snapshot) lv_draw_buf_destroy(snapshot);
snapshot = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_RGB565);
snapshot_row = 0;
if (!snapshot || snapshot->header.w != 480 || snapshot->header.h != 480) {
if (snapshot) lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
ESP_LOGE("ui_image", "IMAGE FAIL allocation/dimensions");
return;
}
ESP_LOGI("ui_image", "IMAGE BEGIN 240 240");
}
inline void snapshot_next_row() {
if (!snapshot) return;
const auto *row = reinterpret_cast<const uint16_t *>(
snapshot->data + snapshot_row * 2 * snapshot->header.stride);
static constexpr char hex[] = "0123456789abcdef";
char line[961];
for (int x = 0; x < 240; ++x) {
uint16_t pixel = row[x * 2];
for (int digit = 0; digit < 4; ++digit)
line[x * 4 + digit] = hex[(pixel >> (12 - digit * 4)) & 15];
}
line[960] = 0;
ESP_LOGI("ui_image", "ROW %03d %s", snapshot_row, line);
if (++snapshot_row == 240) {
lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
ESP_LOGI("ui_image", "IMAGE COMPLETE");
}
}
#endif
} // namespace guition
+221
View File
@@ -0,0 +1,221 @@
#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{}; };
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
}
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(value % 360, 100, 100), LV_PART_KNOB);
} else if (row.index == 1) {
lv_label_set_text_fmt(row.value, "%d K", value);
} 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; 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 (!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 void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) {
if (ready) return;
ready = true;
bool large = width >= 480;
top = large ? 94 : 55; spacing = large ? 110 : 58;
int margin = large ? 24 : 16, w = width - 2 * margin;
int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14;
int track_w = w - 28;
const char *names[] = {"Kleur", "Wittemperatuur", "Helderheid"};
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, spacing - 2);
lv_obj_set_style_text_font(row.box, font, 0);
lv_obj_set_style_text_color(row.box, lv_color_hex(0xEEEEF2), 0);
auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]);
lv_obj_set_pos(label, 2, 0);
row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0);
unsigned segments = i == 0 ? 6 : 1;
for (unsigned n = 0; n < segments; ++n) {
int start = n * track_w / segments, end = (n + 1) * track_w / segments;
auto *stripe = plain(row.box, track_x + 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] : i == 1 ? 0xFF9C32 : 0x40404A), 0);
lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : i == 1 ? 0xC6E6FF : 0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 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_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h);
lv_obj_set_ext_click_area(row.slider, large ? 12 : 8);
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_bg_color(row.slider, lv_color_hex(0xFFFFFF), 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 ? 5 : 6, LV_PART_KNOB);
lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB);
lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB);
lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 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);
preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Even wachten");
}
}
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
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <array>
#include <string>
#include <vector>
#include <cmath>
#include <cstdint>
namespace runtime_tiles {
constexpr size_t MAX_TILES = 10;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == entity.size()) return false;
for (size_t i = 0; i < entity.size(); ++i)
if (i != dot && !(entity[i] >= 'a' && entity[i] <= 'z') &&
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot);
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "button", "input_button"})
if (domain == allowed) return true;
return false;
}
struct Tile {
std::string entity, name, state, unit, modes, hvac_modes;
std::array<std::string, 4> fan_speeds;
unsigned fan_speed_count = 0;
float brightness = NAN, percentage = NAN, position = NAN;
float current = NAN, target = NAN, humidity = NAN, minimum = 7, maximum = 35, step = 0.5f;
int hue = 0, kelvin = 3000, min_kelvin = 0, max_kelvin = 0;
bool received = false;
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool available() const { return received && state != "unknown" && state != "unavailable" && !state.empty(); }
bool active() const { return state == "on" || state == "cleaning" || (domain() == "climate" && available() && state != "off"); }
};
struct Model {
std::array<Tile, MAX_TILES> tiles;
size_t count = 0;
std::string title = "Kies tegels in HA";
bool configured = false;
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
if (entities.size() > MAX_TILES || name.size() > 96) return false;
for (size_t i = 0; i < entities.size(); ++i) {
if (!valid_entity(entities[i])) return false;
for (size_t j = 0; j < i; ++j) if (entities[i] == entities[j]) return false;
}
changed = !configured || count != entities.size();
for (size_t i = 0; i < entities.size(); ++i) if (tiles[i].entity != entities[i]) changed = true;
// A title-only update must not interrupt an open control card.
title = name.empty() ? "Thuis" : name;
if (changed) {
for (auto &tile : tiles) tile = Tile{};
count = entities.size();
for (size_t i = 0; i < count; ++i) tiles[i].entity = entities[i];
}
configured = true;
return true;
}
bool ready() const {
if (!configured) return false;
for (size_t i = 0; i < count; ++i) if (!tiles[i].received) return false;
return true;
}
bool accepts(size_t index, const std::string &entity) const {
return configured && index < count && tiles[index].entity == entity;
}
};
} // namespace runtime_tiles
+190
View File
@@ -0,0 +1,190 @@
#pragma once
#include "runtime_model.h"
#include "cyd_ui.h"
#include "light_controls.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h"
#include "lvgl.h"
#include <functional>
#include <algorithm>
namespace runtime_tiles {
inline bool enabled = false;
inline Model model;
inline int active_index = -1;
inline uint32_t last_received = 0;
inline std::function<void()> layout_changed, refresh, dismiss;
inline std::function<void(Tile &)> detail, detail_update;
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; };
inline std::array<Widgets, MAX_TILES> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback;
float n = value.as<float>();
return std::isfinite(n) ? n : fallback;
}
inline std::string string(JsonVariant value, size_t maximum = 160) {
if (!value.is<const char *>()) return {};
std::string s = value.as<std::string>();
if (s.size() > maximum) {
while (maximum > 0 && (static_cast<unsigned char>(s[maximum]) & 0xC0) == 0x80) --maximum;
s.resize(maximum);
}
return s;
}
inline std::string list(JsonVariant value) {
if (!value.is<JsonArray>()) return {};
std::string out;
serializeJson(value, out);
return out.size() <= 512 ? out : "";
}
inline std::string receive(const std::string &payload) {
if (!enabled) return "Gebruik het Easy Setup-profiel";
if (payload.size() > 4096) return "Fout: bericht te groot";
std::string result = "Fout: ongeldig bericht";
esphome::json::parse_json(payload, [&](JsonObject root) -> bool {
if (root["v"].as<int>() != 1) { result = "Fout: protocolversie"; return false; }
auto op = string(root["op"]);
if (op == "layout") {
if (!root["entities"].is<JsonArray>() || !root["title"].is<const char *>()) return false;
std::vector<std::string> entities;
for (JsonVariant entity : root["entities"].as<JsonArray>()) {
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>());
}
bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
last_received = esphome::millis();
if (layout_changed) layout_changed();
if (refresh) refresh();
result = "Indeling ontvangen";
return true;
}
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
if (!model.accepts(index, entity)) { result = "Fout: verouderde tegel"; return false; }
Tile &tile = model.tiles[index];
auto a = root["a"].as<JsonObject>();
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20);
tile.brightness = number(a["brightness"]);
tile.percentage = number(a["percentage"]);
tile.position = number(a["current_position"]);
tile.current = number(a["current_temperature"]);
tile.target = number(a["temperature"]);
tile.humidity = number(a["current_humidity"]);
tile.minimum = number(a["min_temp"], 7);
tile.maximum = number(a["max_temp"], 35);
tile.step = number(a["target_temp_step"], 0.5f);
tile.modes = list(a["supported_color_modes"]);
tile.hvac_modes = list(a["hvac_modes"]);
float hue = number(a["hs_color"][0]);
if (std::isfinite(hue)) tile.hue = std::lround(std::clamp(hue, 0.0f, 360.0f));
float kelvin = number(a["color_temp_kelvin"]);
if (std::isfinite(kelvin)) tile.kelvin = std::lround(std::clamp(kelvin, 1000.0f, 15000.0f));
tile.min_kelvin = std::clamp(number(a["min_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.max_kelvin = std::clamp(number(a["max_color_temp_kelvin"], 0), 0.0f, 15000.0f);
tile.fan_speed_count = 0;
if (a["fan_speed_list"].is<JsonArray>()) for (JsonVariant speed : a["fan_speed_list"].as<JsonArray>()) {
if (tile.fan_speed_count == 4) break;
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
}
tile.received = true;
last_received = esphome::millis();
if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
return true;
});
return result;
}
inline void action(const std::string &service, const std::string &entity) {
if (!fresh() || !valid_entity(entity)) return;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef(service);
request.data.init(1);
esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef("entity_id");
entry.value = esphome::StringRef(entity);
request.data.push_back(entry);
esphome::api::global_api_server->send_homeassistant_action(request);
}
inline const char *icon_for(const Tile &tile) {
auto d = tile.domain();
if (d == "light") return "\U000F0335";
if (d == "climate") return "\U000F001B";
if (d == "vacuum") return "\U000F070D";
if (d == "fan") return "\U000F0210";
if (d == "cover") return "\U000F111C";
if (d == "scene" || d == "script") return "\U000F04B9";
if (d == "sensor" || d == "binary_sensor") return "\U000F029A";
return "\U000F0425";
}
inline void event(lv_event_t *event) {
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
if (!enabled || !fresh() || w.index >= model.count) return;
auto code = lv_event_get_code(event);
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
if (!cyd::touch_guard.accept(esphome::millis(), 100 + w.index)) return;
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available()) return;
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
if (open) {
if (d == "light" || d == "climate" || d == "vacuum" || d == "fan" || d == "cover") {
active_index = w.index;
if (detail) detail(tile);
}
return;
}
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") action(d + ".toggle", tile.entity);
if (d == "scene" || d == "script") action(d + ".turn_on", tile.entity);
if (d == "button" || d == "input_button") action(d + ".press", tile.entity);
}
inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value, lv_obj_t *circle, lv_obj_t *icon) {
widgets[index] = {tile, title, value, circle, icon, index};
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]);
}
inline void label(lv_obj_t *obj, const std::string &text) {
if (text != lv_label_get_text(obj)) lv_label_set_text(obj, text.c_str());
}
inline void render(lv_obj_t *room) {
if (!enabled) return;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !fresh() ? "HA niet verbonden" : model.title);
for (size_t i = 0; i < model.count; ++i) {
const auto &t = model.tiles[i]; auto &w = widgets[i];
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
std::string value = t.state;
if (!fresh() || !t.available()) value = "Niet beschikbaar";
else if (t.domain() == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (t.domain() == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (value == "on") value = "Aan";
else if (value == "off") value = "Uit";
else if (value == "cleaning") value = "Bezig";
else if (value == "docked") value = "In dock";
else if (!t.unit.empty()) value += " " + t.unit;
label(w.value, value);
bool on = fresh() && t.active();
if (w.cached_active == static_cast<int>(on)) continue;
w.cached_active = on;
uint32_t accent = t.domain() == "light" ? 0xE4B23C : t.domain() == "climate" ? 0xF18750 : 0x548BD4;
lv_obj_set_style_bg_color(w.tile, lv_color_hex(on ? 0xF5F1E8 : 0x202A38), 0);
lv_obj_set_style_bg_color(w.circle, lv_color_hex(on ? accent : 0x303A48), 0);
lv_obj_set_style_text_color(w.icon, lv_color_hex(on ? 0xFFFFFF : accent), 0);
lv_obj_set_style_text_color(w.title, lv_color_hex(on ? 0x172232 : 0xF3F5F7), 0);
lv_obj_set_style_text_color(w.value, lv_color_hex(on ? 0x46525E : 0xAFBAC8), 0);
}
}
inline std::string vacuum_option(unsigned index) {
if (active_index < 0 || static_cast<size_t>(active_index) >= model.count) return {};
auto &tile = model.tiles[active_index];
return index < tile.fan_speed_count ? tile.fan_speeds[index] : "";
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "runtime_tiles.h"
#include "chunk_transport.h"
#include "esphome/components/text/text.h"
#include "esphome/core/component.h"
namespace esphome::smart_display {
// Command transport, not a stored JSON state. HA states stay well below 255 chars.
class DashboardInbox : public text::Text, public Component {
public:
void setup() override { this->publish_state("Klaar voor tegelconfiguratie"); }
protected:
runtime_tiles::Chunks chunks_;
void control(const std::string &value) override {
if (!chunks_.accept(value, millis())) { this->publish_state("Fout: onvolledig bericht"); return; }
if (!chunks_.complete) return;
std::string decoded(chunks_.data.size() * 3 / 4 + 1, '\0');
size_t length = base64_decode(chunks_.data, reinterpret_cast<uint8_t *>(decoded.data()), decoded.size());
chunks_.reset();
if (length == 0 || length > 4096) { this->publish_state("Fout: ongeldige codering"); return; }
decoded.resize(length);
this->publish_state(runtime_tiles::receive(decoded));
}
};
}
+15
View File
@@ -0,0 +1,15 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text
from esphome.const import CONF_ID
AUTO_LOAD = ["json"]
ns = cg.esphome_ns.namespace("smart_display")
DashboardInbox = ns.class_("DashboardInbox", text.Text, cg.Component)
CONFIG_SCHEMA = text.text_schema(DashboardInbox, mode="TEXT", entity_category="config").extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await text.register_text(var, config, min_length=0, max_length=255, pattern=None)